diff options
42 files changed, 2704 insertions, 171 deletions
@@ -11,6 +11,12 @@ Build and System Integration: * Changed `couchdb` script configuration options. * Added default.d and local.d configuration directories to load sequence. +HTTP Interface: + + * Added optional cookie-based authentication handler. + * Added optional two-legged OAuth authentication handler. + + Version 0.9.1 ------------- diff --git a/Makefile.am b/Makefile.am index f41fca5d..6ea817c7 100644 --- a/Makefile.am +++ b/Makefile.am @@ -10,7 +10,7 @@ ## License for the specific language governing permissions and limitations under ## the License. -SUBDIRS = bin etc src/couchdb src/ibrowse src/mochiweb share test var utils +SUBDIRS = bin etc src/couchdb src/erlang-oauth src/ibrowse src/mochiweb share test var utils localdoc_DATA = AUTHORS.gz BUGS.gz CHANGES.gz NEWS.gz README.gz THANKS.gz diff --git a/bin/Makefile.am b/bin/Makefile.am index 991ceef4..4f7b451e 100644 --- a/bin/Makefile.am +++ b/bin/Makefile.am @@ -31,6 +31,7 @@ couchdb: couchdb.tpl -e "s|%couchdbebindir%|couch-@version@/ebin|g" \ -e "s|%mochiwebebindir%|mochiweb-r97/ebin|g" \ -e "s|%ibrowseebindir%|ibrowse-1.4.1/ebin|g" \ + -e "s|%oauthebindir%|erlang-oauth/ebin|g" \ -e "s|%defaultini%|default.ini|g" \ -e "s|%localini%|local.ini|g" \ -e "s|%localconfdir%|@localconfdir@|g" \ diff --git a/bin/couchdb.tpl.in b/bin/couchdb.tpl.in index 033acb97..6f22495c 100644 --- a/bin/couchdb.tpl.in +++ b/bin/couchdb.tpl.in @@ -219,7 +219,9 @@ start_couchdb () { -pa %localerlanglibdir%/%couchdbebindir% \ %localerlanglibdir%/%mochiwebebindir% \ %localerlanglibdir%/%ibrowseebindir% \ + %localerlanglibdir%/%oauthebindir% \ -eval \"application:load(ibrowse)\" \ + -eval \"application:load(oauth)\" \ -eval \"application:load(crypto)\" \ -eval \"application:load(couch)\" \ -eval \"crypto:start()\" \ diff --git a/configure.ac b/configure.ac index a0ced9f3..35fd3de1 100644 --- a/configure.ac +++ b/configure.ac @@ -289,6 +289,7 @@ AC_CONFIG_FILES([share/Makefile]) AC_CONFIG_FILES([src/couchdb/couch.app.tpl]) AC_CONFIG_FILES([src/couchdb/Makefile]) AC_CONFIG_FILES([src/couchdb/priv/Makefile]) +AC_CONFIG_FILES([src/erlang-oauth/Makefile]) AC_CONFIG_FILES([src/ibrowse/Makefile]) AC_CONFIG_FILES([src/mochiweb/Makefile]) AC_CONFIG_FILES([test/Makefile]) diff --git a/configure.ac.orig b/configure.ac.orig new file mode 100644 index 00000000..a0ced9f3 --- /dev/null +++ b/configure.ac.orig @@ -0,0 +1,303 @@ +dnl Licensed under the Apache License, Version 2.0 (the "License"); you may not +dnl use this file except in compliance with the License. dnl You may obtain a +dnl copy of the License at +dnl +dnl http://www.apache.org/licenses/LICENSE-2.0 +dnl +dnl Unless required by applicable law or agreed to in writing, software +dnl distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +dnl WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +dnl License for the specific language governing permissions and limitations +dnl under the License. + +m4_include([m4/ac_check_icu.m4]) + +AC_INIT([LOCAL_PACKAGE_NAME], [LOCAL_VERSION], [], [LOCAL_PACKAGE_TARNAME]) + +AC_PREREQ([2.59]) + +AC_CONFIG_SRCDIR([CHANGES]) +AC_CONFIG_AUX_DIR([build-aux]) + +AM_CONFIG_HEADER([config.h]) + +AM_INIT_AUTOMAKE([1.6.3 foreign]) + +AC_GNU_SOURCE +AC_ENABLE_SHARED +AC_DISABLE_STATIC + +AC_PROG_CC +AC_PROG_LIBTOOL +AC_PROG_LN_S + +AC_MSG_CHECKING([for pthread_create in -lpthread]) + +original_LIBS="$LIBS" +LIBS="-lpthread $original_LIBS" + +AC_TRY_LINK([#include<pthread.h>], + [pthread_create((void *)0, (void *)0, (void *)0, (void *)0)], + [pthread=yes], [pthread=no]) + +if test x${pthread} = xyes; then + AC_MSG_RESULT([yes]) +else + LIBS="$original_LIBS" + AC_MSG_RESULT([no]) +fi + +AC_ARG_WITH([erlang], [AC_HELP_STRING([--with-erlang=PATH], + [set PATH to the Erlang include directory])], [ + ERLANG_FLAGS="-I$withval" +], [ + ERLANG_FLAGS="-I${libdir}/erlang/usr/include" + ERLANG_FLAGS="$ERLANG_FLAGS -I/usr/lib/erlang/usr/include" + ERLANG_FLAGS="$ERLANG_FLAGS -I/usr/local/lib/erlang/usr/include" + ERLANG_FLAGS="$ERLANG_FLAGS -I/opt/local/lib/erlang/usr/include" +]) + +AC_ARG_WITH([js-include], [AC_HELP_STRING([--with-js-include=PATH], + [set PATH to the SpiderMonkey include directory])], [ + JS_INCLUDE="$withval" + JS_FLAGS="-I$JS_INCLUDE" +], [ + JS_FLAGS="-I/usr/include" + JS_FLAGS="$JS_FLAGS -I/usr/include/js" + JS_FLAGS="$JS_FLAGS -I/usr/include/mozjs" + JS_FLAGS="$JS_FLAGS -I/usr/local/include" + JS_FLAGS="$JS_FLAGS -I/opt/local/include" + JS_FLAGS="$JS_FLAGS -I/usr/local/include/js" + JS_FLAGS="$JS_FLAGS -I/opt/local/include/js" +]) + +AC_ARG_WITH([js-lib], [AC_HELP_STRING([--with-js-lib=PATH], + [set PATH to the SpiderMonkey library directory])], + [JS_LIB_FLAGS="-L$withval"], []) + +AC_ARG_VAR([ERLC_FLAGS], [general flags to prepend to ERLC_FLAGS]) +AC_ARG_VAR([FLAGS], [general flags to prepend to LDFLAGS and CPPFLAGS]) + +LIB_FLAGS="$JS_LIB_FLAGS -L/usr/local/lib -L/opt/local/lib" +LIBS="$LIB_FLAGS $LIBS" +# XP_UNIX required for jsapi.h and has been tested to work on Linux and Darwin. +FLAGS="$LIB_FLAGS $ERLANG_FLAGS $JS_FLAGS -DXP_UNIX $FLAGS" +CPPFLAGS="$FLAGS $CPPFLAGS" +# manually linking libm is requred for FreeBSD 7.0 +LDFLAGS="$FLAGS -lm $LDFLAGS" + +AC_CHECK_LIB([mozjs], [JS_NewContext], [], [ + AC_CHECK_LIB([js], [JS_NewContext], [], [ + AC_MSG_ERROR([Could not find the js library. + +Is the Mozilla SpiderMonkey library installed?])])]) + +AC_CHECK_HEADER([jsapi.h], [], [ + AC_CHECK_HEADER([js/jsapi.h], + [ + CPPFLAGS="$CPPFLAGS -I$JS_INCLUDE/js" + ], + [ + AC_MSG_ERROR([Could not find the jsapi header. + +Are the Mozilla SpiderMonkey headers installed?]) + ])]) + +AC_LANG_PUSH(C) +OLD_CFLAGS="$CFLAGS" +CFLAGS="-Werror-implicit-function-declaration" +AC_COMPILE_IFELSE( + [AC_LANG_PROGRAM( + [[#include <jsapi.h>]], + [[JS_SetOperationCallback(0, 0);]] + )], + AC_DEFINE([USE_JS_SETOPCB], [], [Use new JS_SetOperationCallback]) +) +CFLAGS="$OLD_CFLAGS" +AC_LANG_POP(C) + +AC_CHECK_ICU([3]) + +ICU_LOCAL_CFLAGS=`$ICU_CONFIG --cppflags-searchpath` +ICU_LOCAL_LDFLAGS=`$ICU_CONFIG --ldflags-searchpath` + +AC_SUBST(ICU_CONFIG) +AC_SUBST(ICU_LOCAL_CFLAGS) +AC_SUBST(ICU_LOCAL_LDFLAGS) + +AC_CHECK_CURL([7.15.5]) +AC_SUBST(CURL_CFLAGS) +AC_SUBST(CURL_LIBS) + +case "$(uname -s)" in + Linux) + LIBS="$LIBS -lcrypt" + CPPFLAGS="-D_XOPEN_SOURCE $CPPFLAGS" + ;; + FreeBSD) + LIBS="$LIBS -lcrypt" + ;; + OpenBSD) + LIBS="$LIBS -lcrypto" + ;; +esac + +AC_PATH_PROG([ERL], [erl]) + +if test x${ERL} = x; then + AC_MSG_ERROR([Could not find the `erl' executable. Is Erlang installed?]) +fi + +erlang_version_error="The installed Erlang version is less than 5.6.0 (R12B)." + +version="`${ERL} -version 2>&1 | ${SED} "s/[[^0-9]]/ /g"`" + +if test `echo $version | ${AWK} "{print \\$1}"` -lt 5; then + AC_MSG_ERROR([$erlang_version_error]) +fi + +if test `echo $version | ${AWK} "{print \\$2}"` -lt 6; then + AC_MSG_ERROR([$erlang_version_error]) +fi + +AC_PATH_PROG([ERLC], [erlc]) + +if test x${ERLC} = x; then + AC_MSG_ERROR([Could not find the `erlc' executable. Is Erlang installed?]) +fi + +AC_CHECK_HEADER([erl_driver.h], [], [ + AC_MSG_ERROR([Could not find the `erl_driver.h' header. + +Are the Erlang headers installed? Use the `--with-erlang' option to specify the +path to the Erlang include directory.])]) + +AC_PATH_PROG([HELP2MAN_EXECUTABLE], [help2man]) +if test x${HELP2MAN_EXECUTABLE} = x; then + AC_MSG_WARN([You will be unable to regenerate any man pages.]) +fi + +use_init=yes +use_launchd=yes + +AC_ARG_ENABLE([init], [AC_HELP_STRING([--disable-init], + [don't install init script where applicable])], [ + use_init=$enableval +], []) + +AC_ARG_ENABLE([launchd], [AC_HELP_STRING([--disable-launchd], + [don't install launchd configuration where applicable])], [ + use_launchd=$enableval +], []) + +init_enabled=false +launchd_enabled=false + +if test "$use_init" = "yes"; then + AC_MSG_CHECKING(location of init directory) + if test -d /etc/rc.d; then + init_enabled=true + AC_SUBST([initdir], ['${sysconfdir}/rc.d']) + AC_MSG_RESULT(${initdir}) + else + if test -d /etc/init.d; then + init_enabled=true + AC_SUBST([initdir], ['${sysconfdir}/init.d']) + AC_MSG_RESULT(${initdir}) + else + AC_MSG_RESULT(not found) + fi + fi +fi + +if test "$use_launchd" = "yes"; then + AC_MSG_CHECKING(location of launchd directory) + if test -d /Library/LaunchDaemons; then + init_enabled=false + launchd_enabled=true + AC_SUBST([launchddir], ['${prefix}/Library/LaunchDaemons']) + AC_MSG_RESULT(${launchddir}) + else + AC_MSG_RESULT(not found) + fi +fi + +AC_ARG_VAR([ERL], [path to the `erl' executable]) +AC_ARG_VAR([ERLC], [path to the `erlc' executable]) +AC_ARG_VAR([HELP2MAN_EXECUTABLE], [path to the `help2man' program]) + +if test -n "$HELP2MAN_EXECUTABLE"; then + help2man_enabled=true +else + if test -f "$srcdir/bin/couchdb.1" -a -f "$srcdir/bin/couchjs.1"; then + help2man_enabled=true + else + help2man_enabled=false + fi +fi + +AM_CONDITIONAL([INIT], [test x${init_enabled} = xtrue]) +AM_CONDITIONAL([LAUNCHD], [test x${launchd_enabled} = xtrue]) +AM_CONDITIONAL([HELP2MAN], [test x${help2man_enabled} = xtrue]) + +AC_SUBST([package_author_name], ["LOCAL_PACKAGE_AUTHOR_NAME"]) +AC_SUBST([package_author_address], ["LOCAL_PACKAGE_AUTHOR_ADDRESS"]) +AC_SUBST([package_identifier], ["LOCAL_PACKAGE_IDENTIFIER"]) +AC_SUBST([package_tarname], ["LOCAL_PACKAGE_TARNAME"]) +AC_SUBST([package_name], ["LOCAL_PACKAGE_NAME"]) + +AC_SUBST([version], ["LOCAL_VERSION"]) +AC_SUBST([version_major], ["LOCAL_VERSION_MAJOR"]) +AC_SUBST([version_minor], ["LOCAL_VERSION_MINOR"]) +AC_SUBST([version_revision], ["LOCAL_VERSION_REVISION"]) +AC_SUBST([version_stage], ["LOCAL_VERSION_STAGE"]) +AC_SUBST([version_release], ["LOCAL_VERSION_RELEASE"]) + +AC_SUBST([bug_uri], ["LOCAL_BUG_URI"]) + +AC_SUBST([localconfdir], [${sysconfdir}/${package_identifier}]) +AC_SUBST([localdatadir], [${datadir}/${package_identifier}]) +AC_SUBST([localdocdir], [${datadir}/doc/${package_identifier}]) +AC_SUBST([locallibdir], [${libdir}/${package_identifier}]) +AC_SUBST([localstatelibdir], [${localstatedir}/lib/${package_identifier}]) +AC_SUBST([localstatelogdir], [${localstatedir}/log/${package_identifier}]) +AC_SUBST([localstaterundir], [${localstatedir}/run/${package_identifier}]) +AC_SUBST([locallibbindir], [${locallibdir}/bin]) +AC_SUBST([localerlanglibdir], [${locallibdir}/erlang/lib]) + +# fix for older autotools that don't define "abs_top_YYY" by default +AC_SUBST(abs_top_srcdir) +AC_SUBST(abs_top_builddir) + +AC_REVISION([LOCAL_VERSION]) + +AC_CONFIG_FILES([Makefile]) +AC_CONFIG_FILES([bin/couchjs.tpl]) +AC_CONFIG_FILES([bin/couchdb.tpl]) +AC_CONFIG_FILES([bin/Makefile]) +AC_CONFIG_FILES([etc/couchdb/Makefile]) +AC_CONFIG_FILES([etc/couchdb/default.ini.tpl]) +AC_CONFIG_FILES([etc/default/Makefile]) +AC_CONFIG_FILES([etc/init/couchdb.tpl]) +AC_CONFIG_FILES([etc/init/Makefile]) +AC_CONFIG_FILES([etc/launchd/org.apache.couchdb.plist.tpl]) +AC_CONFIG_FILES([etc/launchd/Makefile]) +AC_CONFIG_FILES([etc/logrotate.d/couchdb.tpl]) +AC_CONFIG_FILES([etc/logrotate.d/Makefile]) +AC_CONFIG_FILES([etc/Makefile]) +AC_CONFIG_FILES([share/Makefile]) +AC_CONFIG_FILES([src/couchdb/couch.app.tpl]) +AC_CONFIG_FILES([src/couchdb/Makefile]) +AC_CONFIG_FILES([src/couchdb/priv/Makefile]) +AC_CONFIG_FILES([src/ibrowse/Makefile]) +AC_CONFIG_FILES([src/mochiweb/Makefile]) +AC_CONFIG_FILES([test/Makefile]) +AC_CONFIG_FILES([utils/Makefile]) +AC_CONFIG_FILES([var/Makefile]) + +AC_OUTPUT + +echo +echo "You have configured Apache CouchDB, time to relax." +echo +echo "Run \`make && sudo make install' to install." diff --git a/etc/couchdb/default.ini.tpl.in b/etc/couchdb/default.ini.tpl.in index 77a09fcb..68373691 100644 --- a/etc/couchdb/default.ini.tpl.in +++ b/etc/couchdb/default.ini.tpl.in @@ -16,7 +16,7 @@ batch_save_interval = 1000 ; milliseconds after which to save batches [httpd] port = 5984 bind_address = 127.0.0.1 -authentication_handler = {couch_httpd, default_authentication_handler} +authentication_handlers = {couch_httpd_oauth, oauth_authentication_handler}, {couch_httpd_auth, default_authentication_handler} default_handler = {couch_httpd_db, handle_request} WWW-Authenticate = Basic realm="administrator" @@ -24,6 +24,11 @@ WWW-Authenticate = Basic realm="administrator" file = %localstatelogdir%/couch.log level = info +[couch_httpd_auth] +authentication_db = users +secret = replace this with a real secret in your local.ini file +require_valid_user = false + [query_servers] javascript = %bindir%/%couchjs_command_name% %localdatadir%/server/main.js @@ -62,7 +67,9 @@ _restart = {couch_httpd_misc_handlers, handle_restart_req} _stats = {couch_httpd_stats_handlers, handle_stats_req} _log = {couch_httpd_misc_handlers, handle_log_req} _sleep = {couch_httpd_misc_handlers, handle_sleep_req} -_whoami = {couch_httpd_misc_handlers, handle_whoami_req} +_session = {couch_httpd_auth, handle_session_req} +_oauth = {couch_httpd_oauth, handle_oauth_req} +_user = {couch_httpd_auth, handle_user_req} [httpd_db_handlers] _view_cleanup = {couch_httpd_db, handle_view_cleanup_req} diff --git a/etc/couchdb/local.ini b/etc/couchdb/local.ini index f2dbc4fb..c7027415 100644 --- a/etc/couchdb/local.ini +++ b/etc/couchdb/local.ini @@ -14,6 +14,10 @@ [log] ;level = debug +[couch_httpd_auth] +;secret = replace this with a real secret + + [update_notification] ;unique notifier name=/full/path/to/exe -with "cmd line arg" diff --git a/share/Makefile.am b/share/Makefile.am index 4e584703..8071525f 100644 --- a/share/Makefile.am +++ b/share/Makefile.am @@ -93,6 +93,8 @@ nobase_dist_localdata_DATA = \ www/script/jquery.resizer.js \ www/script/jquery.suggest.js \ www/script/json2.js \ + www/script/oauth.js \ + www/script/sha1.js \ www/script/test/basics.js \ www/script/test/delayed_commits.js \ www/script/test/all_docs.js \ @@ -138,6 +140,8 @@ nobase_dist_localdata_DATA = \ www/script/test/purge.js \ www/script/test/config.js \ www/script/test/security_validation.js \ + www/script/test/cookie_auth.js \ + www/script/test/oauth.js \ www/script/test/stats.js \ www/script/test/changes.js \ www/script/test/lorem.txt \ diff --git a/share/www/script/couch.js b/share/www/script/couch.js index bd1d49dd..f354f52a 100644 --- a/share/www/script/couch.js +++ b/share/www/script/couch.js @@ -299,6 +299,67 @@ function CouchDB(name, httpHeaders) { // Use this from callers to check HTTP status or header values of requests. CouchDB.last_req = null; +CouchDB.login = function(username, password) { + CouchDB.last_req = CouchDB.request("POST", "/_session", { + headers: {"Content-Type": "application/x-www-form-urlencoded", + "X-CouchDB-WWW-Authenticate": "Cookie"}, + body: "username=" + encodeURIComponent(username) + "&password=" + encodeURIComponent(password) + }); + return JSON.parse(CouchDB.last_req.responseText); +} + +CouchDB.logout = function() { + CouchDB.last_req = CouchDB.request("DELETE", "/_session", { + headers: {"Content-Type": "application/x-www-form-urlencoded", + "X-CouchDB-WWW-Authenticate": "Cookie"} + }); + return JSON.parse(CouchDB.last_req.responseText); +} + +CouchDB.createUser = function(username, password, email, roles, basicAuth) { + var roles_str = "" + if (roles) { + for (var i=0; i< roles.length; i++) { + roles_str += "&roles=" + encodeURIComponent(roles[i]); + } + } + var headers = {"Content-Type": "application/x-www-form-urlencoded"}; + if (!basicAuth) { + headers['X-CouchDB-WWW-Authenticate'] = 'Cookie'; + } + + CouchDB.last_req = CouchDB.request("POST", "/_user/", { + headers: headers, + body: "username=" + encodeURIComponent(username) + "&password=" + encodeURIComponent(password) + + "&email="+ encodeURIComponent(email)+ roles_str + + }); + return JSON.parse(CouchDB.last_req.responseText); +} + +CouchDB.updateUser = function(username, email, roles, password, old_password) { + var roles_str = "" + if (roles) { + for (var i=0; i< roles.length; i++) { + roles_str += "&roles=" + encodeURIComponent(roles[i]); + } + } + + var body = "email="+ encodeURIComponent(email)+ roles_str; + + if (typeof(password) != "undefined" && password) + body += "&password=" + password; + + if (typeof(old_password) != "undefined" && old_password) + body += "&old_password=" + old_password; + + CouchDB.last_req = CouchDB.request("PUT", "/_user/"+encodeURIComponent(username), { + headers: {"Content-Type": "application/x-www-form-urlencoded", + "X-CouchDB-WWW-Authenticate": "Cookie"}, + body: body + }); + return JSON.parse(CouchDB.last_req.responseText); +} CouchDB.allDbs = function() { CouchDB.last_req = CouchDB.request("GET", "/_all_dbs"); diff --git a/share/www/script/couch_tests.js b/share/www/script/couch_tests.js index 5ac0f51e..91e95b11 100644 --- a/share/www/script/couch_tests.js +++ b/share/www/script/couch_tests.js @@ -73,6 +73,10 @@ loadTest("purge.js"); loadTest("config.js"); loadTest("form_submit.js"); loadTest("security_validation.js"); +loadTest("cookie_auth.js"); +loadScript("script/sha1.js"); +loadScript("script/oauth.js"); +loadTest("oauth.js"); loadTest("stats.js"); loadTest("rev_stemming.js"); diff --git a/share/www/script/jquery.couch.js b/share/www/script/jquery.couch.js index 73d064c4..54e32617 100644 --- a/share/www/script/jquery.couch.js +++ b/share/www/script/jquery.couch.js @@ -61,6 +61,41 @@ ); }, + // TODO make login/logout and db.login/db.logout DRY + login: function(options) { + options = options || {}; + $.ajax({ + type: "POST", url: "/_login", dataType: "json", + data: {username: options.username, password: options.password}, + complete: function(req) { + var resp = $.httpData(req, "json"); + if (req.status == 200) { + if (options.success) options.success(resp); + } else if (options.error) { + options.error(req.status, resp.error, resp.reason); + } else { + alert("An error occurred logging in: " + resp.reason); + } + } + }); + }, + logout: function(options) { + options = options || {}; + $.ajax({ + type: "POST", url: "/_logout", dataType: "json", + complete: function(req) { + var resp = $.httpData(req, "json"); + if (req.status == 200) { + if (options.success) options.success(resp); + } else if (options.error) { + options.error(req.status, resp.error, resp.reason); + } else { + alert("An error occurred logging out: " + resp.reason); + } + } + }); + }, + db: function(name) { return { name: name, diff --git a/share/www/script/oauth.js b/share/www/script/oauth.js new file mode 100644 index 00000000..aa0019d5 --- /dev/null +++ b/share/www/script/oauth.js @@ -0,0 +1,511 @@ +/* + * Copyright 2008 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* Here's some JavaScript software for implementing OAuth. + + This isn't as useful as you might hope. OAuth is based around + allowing tools and websites to talk to each other. However, + JavaScript running in web browsers is hampered by security + restrictions that prevent code running on one website from + accessing data stored or served on another. + + Before you start hacking, make sure you understand the limitations + posed by cross-domain XMLHttpRequest. + + On the bright side, some platforms use JavaScript as their + language, but enable the programmer to access other web sites. + Examples include Google Gadgets, and Microsoft Vista Sidebar. + For those platforms, this library should come in handy. +*/ + +// The HMAC-SHA1 signature method calls b64_hmac_sha1, defined by +// http://pajhome.org.uk/crypt/md5/sha1.js + +/* An OAuth message is represented as an object like this: + {method: "GET", action: "http://server.com/path", parameters: ...} + + The parameters may be either a map {name: value, name2: value2} + or an Array of name-value pairs [[name, value], [name2, value2]]. + The latter representation is more powerful: it supports parameters + in a specific sequence, or several parameters with the same name; + for example [["a", 1], ["b", 2], ["a", 3]]. + + Parameter names and values are NOT percent-encoded in an object. + They must be encoded before transmission and decoded after reception. + For example, this message object: + {method: "GET", action: "http://server/path", parameters: {p: "x y"}} + ... can be transmitted as an HTTP request that begins: + GET /path?p=x%20y HTTP/1.0 + (This isn't a valid OAuth request, since it lacks a signature etc.) + Note that the object "x y" is transmitted as x%20y. To encode + parameters, you can call OAuth.addToURL, OAuth.formEncode or + OAuth.getAuthorization. + + This message object model harmonizes with the browser object model for + input elements of an form, whose value property isn't percent encoded. + The browser encodes each value before transmitting it. For example, + see consumer.setInputs in example/consumer.js. + */ +var OAuth; if (OAuth == null) OAuth = {}; + +OAuth.setProperties = function setProperties(into, from) { + if (into != null && from != null) { + for (var key in from) { + into[key] = from[key]; + } + } + return into; +} + +OAuth.setProperties(OAuth, // utility functions +{ + percentEncode: function percentEncode(s) { + if (s == null) { + return ""; + } + if (s instanceof Array) { + var e = ""; + for (var i = 0; i < s.length; ++s) { + if (e != "") e += '&'; + e += percentEncode(s[i]); + } + return e; + } + s = encodeURIComponent(s); + // Now replace the values which encodeURIComponent doesn't do + // encodeURIComponent ignores: - _ . ! ~ * ' ( ) + // OAuth dictates the only ones you can ignore are: - _ . ~ + // Source: http://developer.mozilla.org/en/docs/Core_JavaScript_1.5_Reference:Global_Functions:encodeURIComponent + s = s.replace(/\!/g, "%21"); + s = s.replace(/\*/g, "%2A"); + s = s.replace(/\'/g, "%27"); + s = s.replace(/\(/g, "%28"); + s = s.replace(/\)/g, "%29"); + return s; + } +, + decodePercent: function decodePercent(s) { + if (s != null) { + // Handle application/x-www-form-urlencoded, which is defined by + // http://www.w3.org/TR/html4/interact/forms.html#h-17.13.4.1 + s = s.replace(/\+/g, " "); + } + return decodeURIComponent(s); + } +, + /** Convert the given parameters to an Array of name-value pairs. */ + getParameterList: function getParameterList(parameters) { + if (parameters == null) { + return []; + } + if (typeof parameters != "object") { + return decodeForm(parameters + ""); + } + if (parameters instanceof Array) { + return parameters; + } + var list = []; + for (var p in parameters) { + list.push([p, parameters[p]]); + } + return list; + } +, + /** Convert the given parameters to a map from name to value. */ + getParameterMap: function getParameterMap(parameters) { + if (parameters == null) { + return {}; + } + if (typeof parameters != "object") { + return getParameterMap(decodeForm(parameters + "")); + } + if (parameters instanceof Array) { + var map = {}; + for (var p = 0; p < parameters.length; ++p) { + var key = parameters[p][0]; + if (map[key] === undefined) { // first value wins + map[key] = parameters[p][1]; + } + } + return map; + } + return parameters; + } +, + getParameter: function getParameter(parameters, name) { + if (parameters instanceof Array) { + for (var p = 0; p < parameters.length; ++p) { + if (parameters[p][0] == name) { + return parameters[p][1]; // first value wins + } + } + } else { + return OAuth.getParameterMap(parameters)[name]; + } + return null; + } +, + formEncode: function formEncode(parameters) { + var form = ""; + var list = OAuth.getParameterList(parameters); + for (var p = 0; p < list.length; ++p) { + var value = list[p][1]; + if (value == null) value = ""; + if (form != "") form += '&'; + form += OAuth.percentEncode(list[p][0]) + +'='+ OAuth.percentEncode(value); + } + return form; + } +, + decodeForm: function decodeForm(form) { + var list = []; + var nvps = form.split('&'); + for (var n = 0; n < nvps.length; ++n) { + var nvp = nvps[n]; + if (nvp == "") { + continue; + } + var equals = nvp.indexOf('='); + var name; + var value; + if (equals < 0) { + name = OAuth.decodePercent(nvp); + value = null; + } else { + name = OAuth.decodePercent(nvp.substring(0, equals)); + value = OAuth.decodePercent(nvp.substring(equals + 1)); + } + list.push([name, value]); + } + return list; + } +, + setParameter: function setParameter(message, name, value) { + var parameters = message.parameters; + if (parameters instanceof Array) { + for (var p = 0; p < parameters.length; ++p) { + if (parameters[p][0] == name) { + if (value === undefined) { + parameters.splice(p, 1); + } else { + parameters[p][1] = value; + value = undefined; + } + } + } + if (value !== undefined) { + parameters.push([name, value]); + } + } else { + parameters = OAuth.getParameterMap(parameters); + parameters[name] = value; + message.parameters = parameters; + } + } +, + setParameters: function setParameters(message, parameters) { + var list = OAuth.getParameterList(parameters); + for (var i = 0; i < list.length; ++i) { + OAuth.setParameter(message, list[i][0], list[i][1]); + } + } +, + /** Fill in parameters to help construct a request message. + This function doesn't fill in every parameter. + The accessor object should be like: + {consumerKey:'foo', consumerSecret:'bar', accessorSecret:'nurn', token:'krelm', tokenSecret:'blah'} + The accessorSecret property is optional. + */ + completeRequest: function completeRequest(message, accessor) { + if (message.method == null) { + message.method = "GET"; + } + var map = OAuth.getParameterMap(message.parameters); + if (map.oauth_consumer_key == null) { + OAuth.setParameter(message, "oauth_consumer_key", accessor.consumerKey || ""); + } + if (map.oauth_token == null && accessor.token != null) { + OAuth.setParameter(message, "oauth_token", accessor.token); + } + if (map.oauth_version == null) { + OAuth.setParameter(message, "oauth_version", "1.0"); + } + if (map.oauth_timestamp == null) { + OAuth.setParameter(message, "oauth_timestamp", OAuth.timestamp()); + } + if (map.oauth_nonce == null) { + OAuth.setParameter(message, "oauth_nonce", OAuth.nonce(6)); + } + OAuth.SignatureMethod.sign(message, accessor); + } +, + setTimestampAndNonce: function setTimestampAndNonce(message) { + OAuth.setParameter(message, "oauth_timestamp", OAuth.timestamp()); + OAuth.setParameter(message, "oauth_nonce", OAuth.nonce(6)); + } +, + addToURL: function addToURL(url, parameters) { + newURL = url; + if (parameters != null) { + var toAdd = OAuth.formEncode(parameters); + if (toAdd.length > 0) { + var q = url.indexOf('?'); + if (q < 0) newURL += '?'; + else newURL += '&'; + newURL += toAdd; + } + } + return newURL; + } +, + /** Construct the value of the Authorization header for an HTTP request. */ + getAuthorizationHeader: function getAuthorizationHeader(realm, parameters) { + var header = 'OAuth realm="' + OAuth.percentEncode(realm) + '"'; + var list = OAuth.getParameterList(parameters); + for (var p = 0; p < list.length; ++p) { + var parameter = list[p]; + var name = parameter[0]; + if (name.indexOf("oauth_") == 0) { + header += ',' + OAuth.percentEncode(name) + '="' + OAuth.percentEncode(parameter[1]) + '"'; + } + } + return header; + } +, + timestamp: function timestamp() { + var d = new Date(); + return Math.floor(d.getTime()/1000); + } +, + nonce: function nonce(length) { + var chars = OAuth.nonce.CHARS; + var result = ""; + for (var i = 0; i < length; ++i) { + var rnum = Math.floor(Math.random() * chars.length); + result += chars.substring(rnum, rnum+1); + } + return result; + } +}); + +OAuth.nonce.CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz"; + +/** Define a constructor function, + without causing trouble to anyone who was using it as a namespace. + That is, if parent[name] already existed and had properties, + copy those properties into the new constructor. + */ +OAuth.declareClass = function declareClass(parent, name, newConstructor) { + var previous = parent[name]; + parent[name] = newConstructor; + if (newConstructor != null && previous != null) { + for (var key in previous) { + if (key != "prototype") { + newConstructor[key] = previous[key]; + } + } + } + return newConstructor; +} + +/** An abstract algorithm for signing messages. */ +OAuth.declareClass(OAuth, "SignatureMethod", function OAuthSignatureMethod(){}); + +OAuth.setProperties(OAuth.SignatureMethod.prototype, // instance members +{ + /** Add a signature to the message. */ + sign: function sign(message) { + var baseString = OAuth.SignatureMethod.getBaseString(message); + var signature = this.getSignature(baseString); + OAuth.setParameter(message, "oauth_signature", signature); + return signature; // just in case someone's interested + } +, + /** Set the key string for signing. */ + initialize: function initialize(name, accessor) { + var consumerSecret; + if (accessor.accessorSecret != null + && name.length > 9 + && name.substring(name.length-9) == "-Accessor") + { + consumerSecret = accessor.accessorSecret; + } else { + consumerSecret = accessor.consumerSecret; + } + this.key = OAuth.percentEncode(consumerSecret) + +"&"+ OAuth.percentEncode(accessor.tokenSecret); + } +}); + +/* SignatureMethod expects an accessor object to be like this: + {tokenSecret: "lakjsdflkj...", consumerSecret: "QOUEWRI..", accessorSecret: "xcmvzc..."} + The accessorSecret property is optional. + */ +// Class members: +OAuth.setProperties(OAuth.SignatureMethod, // class members +{ + sign: function sign(message, accessor) { + var name = OAuth.getParameterMap(message.parameters).oauth_signature_method; + if (name == null || name == "") { + name = "HMAC-SHA1"; + OAuth.setParameter(message, "oauth_signature_method", name); + } + OAuth.SignatureMethod.newMethod(name, accessor).sign(message); + } +, + /** Instantiate a SignatureMethod for the given method name. */ + newMethod: function newMethod(name, accessor) { + var impl = OAuth.SignatureMethod.REGISTERED[name]; + if (impl != null) { + var method = new impl(); + method.initialize(name, accessor); + return method; + } + var err = new Error("signature_method_rejected"); + var acceptable = ""; + for (var r in OAuth.SignatureMethod.REGISTERED) { + if (acceptable != "") acceptable += '&'; + acceptable += OAuth.percentEncode(r); + } + err.oauth_acceptable_signature_methods = acceptable; + throw err; + } +, + /** A map from signature method name to constructor. */ + REGISTERED : {} +, + /** Subsequently, the given constructor will be used for the named methods. + The constructor will be called with no parameters. + The resulting object should usually implement getSignature(baseString). + You can easily define such a constructor by calling makeSubclass, below. + */ + registerMethodClass: function registerMethodClass(names, classConstructor) { + for (var n = 0; n < names.length; ++n) { + OAuth.SignatureMethod.REGISTERED[names[n]] = classConstructor; + } + } +, + /** Create a subclass of OAuth.SignatureMethod, with the given getSignature function. */ + makeSubclass: function makeSubclass(getSignatureFunction) { + var superClass = OAuth.SignatureMethod; + var subClass = function() { + superClass.call(this); + }; + subClass.prototype = new superClass(); + // Delete instance variables from prototype: + // delete subclass.prototype... There aren't any. + subClass.prototype.getSignature = getSignatureFunction; + subClass.prototype.constructor = subClass; + return subClass; + } +, + getBaseString: function getBaseString(message) { + var URL = message.action; + var q = URL.indexOf('?'); + var parameters; + if (q < 0) { + parameters = message.parameters; + } else { + // Combine the URL query string with the other parameters: + parameters = OAuth.decodeForm(URL.substring(q + 1)); + var toAdd = OAuth.getParameterList(message.parameters); + for (var a = 0; a < toAdd.length; ++a) { + parameters.push(toAdd[a]); + } + } + return OAuth.percentEncode(message.method.toUpperCase()) + +'&'+ OAuth.percentEncode(OAuth.SignatureMethod.normalizeUrl(URL)) + +'&'+ OAuth.percentEncode(OAuth.SignatureMethod.normalizeParameters(parameters)); + } +, + normalizeUrl: function normalizeUrl(url) { + var uri = OAuth.SignatureMethod.parseUri(url); + var scheme = uri.protocol.toLowerCase(); + var authority = uri.authority.toLowerCase(); + var dropPort = (scheme == "http" && uri.port == 80) + || (scheme == "https" && uri.port == 443); + if (dropPort) { + // find the last : in the authority + var index = authority.lastIndexOf(":"); + if (index >= 0) { + authority = authority.substring(0, index); + } + } + var path = uri.path; + if (!path) { + path = "/"; // conforms to RFC 2616 section 3.2.2 + } + // we know that there is no query and no fragment here. + return scheme + "://" + authority + path; + } +, + parseUri: function parseUri (str) { + /* This function was adapted from parseUri 1.2.1 + http://stevenlevithan.com/demo/parseuri/js/assets/parseuri.js + */ + var o = {key: ["source","protocol","authority","userInfo","user","password","host","port","relative","path","directory","file","query","anchor"], + parser: {strict: /^(?:([^:\/?#]+):)?(?:\/\/((?:(([^:@]*):?([^:@]*))?@)?([^:\/?#]*)(?::(\d*))?))?((((?:[^?#\/]*\/)*)([^?#]*))(?:\?([^#]*))?(?:#(.*))?)/ }}; + var m = o.parser.strict.exec(str); + var uri = {}; + var i = 14; + while (i--) uri[o.key[i]] = m[i] || ""; + return uri; + } +, + normalizeParameters: function normalizeParameters(parameters) { + if (parameters == null) { + return ""; + } + var list = OAuth.getParameterList(parameters); + var sortable = []; + for (var p = 0; p < list.length; ++p) { + var nvp = list[p]; + if (nvp[0] != "oauth_signature") { + sortable.push([ OAuth.percentEncode(nvp[0]) + + " " // because it comes before any character that can appear in a percentEncoded string. + + OAuth.percentEncode(nvp[1]) + , nvp]); + } + } + sortable.sort(function(a,b) { + if (a[0] < b[0]) return -1; + if (a[0] > b[0]) return 1; + return 0; + }); + var sorted = []; + for (var s = 0; s < sortable.length; ++s) { + sorted.push(sortable[s][1]); + } + return OAuth.formEncode(sorted); + } +}); + +OAuth.SignatureMethod.registerMethodClass(["PLAINTEXT", "PLAINTEXT-Accessor"], + OAuth.SignatureMethod.makeSubclass( + function getSignature(baseString) { + return this.key; + } + )); + +OAuth.SignatureMethod.registerMethodClass(["HMAC-SHA1", "HMAC-SHA1-Accessor"], + OAuth.SignatureMethod.makeSubclass( + function getSignature(baseString) { + b64pad = '='; + var signature = b64_hmac_sha1(this.key, baseString); + return signature; + } + )); diff --git a/share/www/script/sha1.js b/share/www/script/sha1.js new file mode 100644 index 00000000..1b559823 --- /dev/null +++ b/share/www/script/sha1.js @@ -0,0 +1,202 @@ +/*
+ * A JavaScript implementation of the Secure Hash Algorithm, SHA-1, as defined
+ * in FIPS PUB 180-1
+ * Version 2.1a Copyright Paul Johnston 2000 - 2002.
+ * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet
+ * Distributed under the BSD License
+ * See http://pajhome.org.uk/crypt/md5 for details.
+ */
+
+/*
+ * Configurable variables. You may need to tweak these to be compatible with
+ * the server-side, but the defaults work in most cases.
+ */
+var hexcase = 0; /* hex output format. 0 - lowercase; 1 - uppercase */
+var b64pad = ""; /* base-64 pad character. "=" for strict RFC compliance */
+var chrsz = 8; /* bits per input character. 8 - ASCII; 16 - Unicode */
+
+/*
+ * These are the functions you'll usually want to call
+ * They take string arguments and return either hex or base-64 encoded strings
+ */
+function hex_sha1(s){return binb2hex(core_sha1(str2binb(s),s.length * chrsz));}
+function b64_sha1(s){return binb2b64(core_sha1(str2binb(s),s.length * chrsz));}
+function str_sha1(s){return binb2str(core_sha1(str2binb(s),s.length * chrsz));}
+function hex_hmac_sha1(key, data){ return binb2hex(core_hmac_sha1(key, data));}
+function b64_hmac_sha1(key, data){ return binb2b64(core_hmac_sha1(key, data));}
+function str_hmac_sha1(key, data){ return binb2str(core_hmac_sha1(key, data));}
+
+/*
+ * Perform a simple self-test to see if the VM is working
+ */
+function sha1_vm_test()
+{
+ return hex_sha1("abc") == "a9993e364706816aba3e25717850c26c9cd0d89d";
+}
+
+/*
+ * Calculate the SHA-1 of an array of big-endian words, and a bit length
+ */
+function core_sha1(x, len)
+{
+ /* append padding */
+ x[len >> 5] |= 0x80 << (24 - len % 32);
+ x[((len + 64 >> 9) << 4) + 15] = len;
+
+ var w = Array(80);
+ var a = 1732584193;
+ var b = -271733879;
+ var c = -1732584194;
+ var d = 271733878;
+ var e = -1009589776;
+
+ for(var i = 0; i < x.length; i += 16)
+ {
+ var olda = a;
+ var oldb = b;
+ var oldc = c;
+ var oldd = d;
+ var olde = e;
+
+ for(var j = 0; j < 80; j++)
+ {
+ if(j < 16) w[j] = x[i + j];
+ else w[j] = rol(w[j-3] ^ w[j-8] ^ w[j-14] ^ w[j-16], 1);
+ var t = safe_add(safe_add(rol(a, 5), sha1_ft(j, b, c, d)),
+ safe_add(safe_add(e, w[j]), sha1_kt(j)));
+ e = d;
+ d = c;
+ c = rol(b, 30);
+ b = a;
+ a = t;
+ }
+
+ a = safe_add(a, olda);
+ b = safe_add(b, oldb);
+ c = safe_add(c, oldc);
+ d = safe_add(d, oldd);
+ e = safe_add(e, olde);
+ }
+ return Array(a, b, c, d, e);
+
+}
+
+/*
+ * Perform the appropriate triplet combination function for the current
+ * iteration
+ */
+function sha1_ft(t, b, c, d)
+{
+ if(t < 20) return (b & c) | ((~b) & d);
+ if(t < 40) return b ^ c ^ d;
+ if(t < 60) return (b & c) | (b & d) | (c & d);
+ return b ^ c ^ d;
+}
+
+/*
+ * Determine the appropriate additive constant for the current iteration
+ */
+function sha1_kt(t)
+{
+ return (t < 20) ? 1518500249 : (t < 40) ? 1859775393 :
+ (t < 60) ? -1894007588 : -899497514;
+}
+
+/*
+ * Calculate the HMAC-SHA1 of a key and some data
+ */
+function core_hmac_sha1(key, data)
+{
+ var bkey = str2binb(key);
+ if(bkey.length > 16) bkey = core_sha1(bkey, key.length * chrsz);
+
+ var ipad = Array(16), opad = Array(16);
+ for(var i = 0; i < 16; i++)
+ {
+ ipad[i] = bkey[i] ^ 0x36363636;
+ opad[i] = bkey[i] ^ 0x5C5C5C5C;
+ }
+
+ var hash = core_sha1(ipad.concat(str2binb(data)), 512 + data.length * chrsz);
+ return core_sha1(opad.concat(hash), 512 + 160);
+}
+
+/*
+ * Add integers, wrapping at 2^32. This uses 16-bit operations internally
+ * to work around bugs in some JS interpreters.
+ */
+function safe_add(x, y)
+{
+ var lsw = (x & 0xFFFF) + (y & 0xFFFF);
+ var msw = (x >> 16) + (y >> 16) + (lsw >> 16);
+ return (msw << 16) | (lsw & 0xFFFF);
+}
+
+/*
+ * Bitwise rotate a 32-bit number to the left.
+ */
+function rol(num, cnt)
+{
+ return (num << cnt) | (num >>> (32 - cnt));
+}
+
+/*
+ * Convert an 8-bit or 16-bit string to an array of big-endian words
+ * In 8-bit function, characters >255 have their hi-byte silently ignored.
+ */
+function str2binb(str)
+{
+ var bin = Array();
+ var mask = (1 << chrsz) - 1;
+ for(var i = 0; i < str.length * chrsz; i += chrsz)
+ bin[i>>5] |= (str.charCodeAt(i / chrsz) & mask) << (32 - chrsz - i%32);
+ return bin;
+}
+
+/*
+ * Convert an array of big-endian words to a string
+ */
+function binb2str(bin)
+{
+ var str = "";
+ var mask = (1 << chrsz) - 1;
+ for(var i = 0; i < bin.length * 32; i += chrsz)
+ str += String.fromCharCode((bin[i>>5] >>> (32 - chrsz - i%32)) & mask);
+ return str;
+}
+
+/*
+ * Convert an array of big-endian words to a hex string.
+ */
+function binb2hex(binarray)
+{
+ var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef";
+ var str = "";
+ for(var i = 0; i < binarray.length * 4; i++)
+ {
+ str += hex_tab.charAt((binarray[i>>2] >> ((3 - i%4)*8+4)) & 0xF) +
+ hex_tab.charAt((binarray[i>>2] >> ((3 - i%4)*8 )) & 0xF);
+ }
+ return str;
+}
+
+/*
+ * Convert an array of big-endian words to a base-64 string
+ */
+function binb2b64(binarray)
+{
+ var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ var str = "";
+ for(var i = 0; i < binarray.length * 4; i += 3)
+ {
+ var triplet = (((binarray[i >> 2] >> 8 * (3 - i %4)) & 0xFF) << 16)
+ | (((binarray[i+1 >> 2] >> 8 * (3 - (i+1)%4)) & 0xFF) << 8 )
+ | ((binarray[i+2 >> 2] >> 8 * (3 - (i+2)%4)) & 0xFF);
+ for(var j = 0; j < 4; j++)
+ {
+ if(i * 8 + j * 6 > binarray.length * 32) str += b64pad;
+ else str += tab.charAt((triplet >> 6*(3-j)) & 0x3F);
+ }
+ }
+ return str;
+}
diff --git a/share/www/script/test/changes.js b/share/www/script/test/changes.js index 1800ca82..d8abcc71 100644 --- a/share/www/script/test/changes.js +++ b/share/www/script/test/changes.js @@ -195,8 +195,8 @@ couchTests.changes = function(debug) { // test for userCtx run_on_modified_server( [{section: "httpd", - key: "authentication_handler", - value: "{couch_httpd, special_test_authentication_handler}"}, + key: "authentication_handlers", + value: "{couch_httpd_auth, special_test_authentication_handler}"}, {section:"httpd", key: "WWW-Authenticate", value: "X-Couch-Test-Auth"}], diff --git a/share/www/script/test/cookie_auth.js b/share/www/script/test/cookie_auth.js new file mode 100644 index 00000000..63ab21b9 --- /dev/null +++ b/share/www/script/test/cookie_auth.js @@ -0,0 +1,162 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy +// of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +couchTests.cookie_auth = function(debug) { + // This tests cookie-based authentication. + + var db = new CouchDB("test_suite_db"); + db.deleteDb(); + db.createDb(); + if (debug) debugger; + + // Simple secret key generator + function generateSecret(length) { + var secret = ''; + for (var i=0; i<length; i++) { + secret += String.fromCharCode(Math.floor(Math.random() * 256)); + } + return secret; + } + // this function will be called on the modified server + var testFun = function () { + try { + // try using an invalid cookie + var usersDb = new CouchDB("test_suite_users"); + usersDb.deleteDb(); + usersDb.createDb(); + + var password = "3.141592653589"; + + // Create a user + T(usersDb.save({ + _id: "a1", + salt: "123", + password_sha: "8da1CtkFvb58LWrnup5chgdZVUs=", + username: "Jason Davies", + author: "Jason Davies", + type: "user", + roles: ["_admin"] + }).ok); + + var validationDoc = { + _id : "_design/validate", + validate_doc_update: "(" + (function (newDoc, oldDoc, userCtx) { + // docs should have an author field. + if (!newDoc._deleted && !newDoc.author) { + throw {forbidden: + "Documents must have an author field"}; + } + if (oldDoc && oldDoc.author != userCtx.name) { + throw {unauthorized: + "You are not the author of this document. You jerk."+userCtx.name}; + } + }).toString() + ")" + }; + + T(db.save(validationDoc).ok); + + + + T(CouchDB.login('Jason Davies', password).ok); + // update the credentials document + var doc = usersDb.open("a1"); + doc.foo=2; + T(usersDb.save(doc).ok); + + // Save a document that's missing an author field. + try { + // db has a validation function + db.save({foo:1}); + T(false && "Can't get here. Should have thrown an error 2"); + } catch (e) { + T(e.error == "forbidden"); + T(db.last_req.status == 403); + } + + // TODO should login() throw an exception here? + T(!CouchDB.login('Jason Davies', "2.71828").ok); + T(!CouchDB.login('Robert Allen Zimmerman', 'd00d').ok); + + // test redirect + xhr = CouchDB.request("POST", "/_session?next=/", { + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + body: "username=Jason%20Davies&password="+encodeURIComponent(password) + }); + // should this be a redirect code instead of 200? + T(xhr.status == 200); + + usersDb.deleteDb(); + // test user creation + T(CouchDB.createUser("test", "testpassword", "test@somemail.com", ['read', 'write']).ok); + + // make sure we create a unique user + T(!CouchDB.createUser("test", "testpassword2", "test2@somemail.com", ['read', 'write']).ok); + + // test login + T(CouchDB.login("test", "testpassword").ok); + T(!CouchDB.login('test', "testpassword2").ok); + + // test update user without changing password + T(CouchDB.updateUser("test", "test2@somemail.com").ok); + result = usersDb.view("_auth/users", {key: "test"}); + T(result.rows[0].value['email'] == "test2@somemail.com"); + + + // test changing password + result = usersDb.view("_auth/users", {key: "test"}); + T(CouchDB.updateUser("test", "test2@somemail.com", [], "testpassword2", "testpassword").ok); + result1 = usersDb.view("_auth/users", {key: "test"}); + T(result.rows[0].value['password_sha'] != result1.rows[0].value['password_sha']); + + + // test changing password with passing old password + T(!CouchDB.updateUser("test", "test2@somemail.com", [], "testpassword2").ok); + + // test changing password whith bad old password + T(!CouchDB.updateUser("test", "test2@somemail.com", [], "testpassword2", "badpasswword").ok); + + // Only admins can change roles + T(!CouchDB.updateUser("test", "test2@somemail.com", ['read', 'write']).ok); + + T(CouchDB.logout().ok); + + T(CouchDB.updateUser("test", "test2@somemail.com").ok); + result = usersDb.view("_auth/users", {key: "test"}); + T(result.rows[0].value['email'] == "test2@somemail.com"); + + // test changing password, we don't need to set old password when we are admin + result = usersDb.view("_auth/users", {key: "test"}); + T(CouchDB.updateUser("test", "test2@somemail.com", [], "testpassword3").ok); + result1 = usersDb.view("_auth/users", {key: "test"}); + T(result.rows[0].value['password_sha'] != result1.rows[0].value['password_sha']); + + // Only admins can change roles + T(CouchDB.updateUser("test", "test2@somemail.com", ['read']).ok); + + } finally { + // Make sure we erase any auth cookies so we don't affect other tests + T(CouchDB.logout().ok); + } + }; + + run_on_modified_server( + [{section: "httpd", + key: "authentication_handlers", + value: "{couch_httpd_auth, cookie_authentication_handler}"}, + {section: "couch_httpd_auth", + key: "secret", value: generateSecret(64)}, + {section: "couch_httpd_auth", + key: "authentication_db", value: "test_suite_users"}], + testFun + ); + +}; diff --git a/share/www/script/test/oauth.js b/share/www/script/test/oauth.js new file mode 100644 index 00000000..fb4ab818 --- /dev/null +++ b/share/www/script/test/oauth.js @@ -0,0 +1,166 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy +// of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +couchTests.oauth = function(debug) { + // This tests OAuth authentication. + + var authorization_url = "/_oauth/authorize"; + + var db = new CouchDB("test_suite_db"); + db.deleteDb(); + db.createDb(); + if (debug) debugger; + + var dbA = new CouchDB("test_suite_db_a"); + var dbB = new CouchDB("test_suite_db_b"); + dbA.deleteDb(); + dbA.createDb(); + dbB.deleteDb(); + dbB.createDb(); + + // Simple secret key generator + function generateSecret(length) { + var secret = ''; + for (var i=0; i<length; i++) { + secret += String.fromCharCode(Math.floor(Math.random() * 256)); + } + return secret; + } + + function oauthRequest(path, message, accessor, method) { + message.action = path; + message.method = method || 'GET'; + OAuth.SignatureMethod.sign(message, accessor); + var parameters = message.parameters; + if (method == "POST" || method == "GET") { + if (method == "GET") { + return CouchDB.request("GET", OAuth.addToURL(path, parameters)); + } else { + return CouchDB.request("POST", path, { + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + body: OAuth.formEncode(parameters) + }); + } + } else { + return CouchDB.request("GET", path, { + headers: {Authorization: OAuth.getAuthorizationHeader('', parameters)} + }); + } + } + + var consumerSecret = generateSecret(64); + var tokenSecret = generateSecret(64); + + var host = CouchDB.host; + var dbPair = { + source: { + url: "http://" + host + "/test_suite_db_a", + auth: { + oauth: { + consumer_key: "key", + consumer_secret: consumerSecret, + token_secret: tokenSecret, + token: "foo" + } + } + }, + target: "http://" + host + "/test_suite_db_b" + }; + + // this function will be called on the modified server + var testFun = function () { + try { + var usersDb = new CouchDB("test_suite_users"); + usersDb.deleteDb(); + usersDb.createDb(); + + // Create a user + T(CouchDB.createUser("jason", "testpassword", "test@somemail.com", ['test'], true).ok); + + var accessor = { + consumerSecret: consumerSecret, + tokenSecret: tokenSecret + }; + + var signatureMethods = ["PLAINTEXT", "HMAC-SHA1"]; + var consumerKeys = {key: 200, nonexistent_key: 400}; + + for (var i=0; i<signatureMethods.length; i++) { + for (var consumerKey in consumerKeys) { + var expectedCode = consumerKeys[consumerKey]; + var message = { + parameters: { + oauth_signature_method: signatureMethods[i], + oauth_consumer_key: consumerKey, + oauth_token: "foo", + oauth_token_secret: tokenSecret, + oauth_version: "1.0" + } + }; + + // Get request token via Authorization header + xhr = oauthRequest("http://" + host + "/_oauth/request_token", message, accessor); + T(xhr.status == expectedCode); + + // GET request token via query parameters + xhr = oauthRequest("http://" + host + "/_oauth/request_token", message, accessor, "GET"); + T(xhr.status == expectedCode); + + responseMessage = OAuth.decodeForm(xhr.responseText); + + // Obtaining User Authorization + //Only needed for 3-legged OAuth + //xhr = CouchDB.request("GET", authorization_url + '?oauth_token=' + responseMessage.oauth_token); + //T(xhr.status == expectedCode); + + xhr = oauthRequest("http://" + host + "/_session", message, accessor); + T(xhr.status == expectedCode); + if (xhr.status == expectedCode == 200) { + data = JSON.parse(xhr.responseText); + T(data.name == "jason"); + T(data.roles[0] == "test"); + } + + xhr = oauthRequest("http://" + host + "/_session?foo=bar", message, accessor); + T(xhr.status == expectedCode); + + // Replication + var result = CouchDB.replicate(dbPair.source, dbPair.target); + T(result.ok); + } + } + + } finally { + } + }; + + run_on_modified_server( + [{section: "httpd", + key: "WWW-Authenticate", value: 'Basic realm="administrator",OAuth'}, + {section: "couch_httpd_auth", + key: "secret", value: generateSecret(64)}, + {section: "couch_httpd_auth", + key: "authentication_db", value: "test_suite_users"}, + {section: "oauth_consumer_secrets", + key: "key", value: consumerSecret}, + {section: "oauth_token_users", + key: "foo", value: "jason"}, + {section: "oauth_token_secrets", + key: "foo", value: tokenSecret}, + {section: "couch_httpd_oauth", + key: "authorization_url", value: authorization_url}, + {section: "httpd", + key: "authentication_handlers", + value: "{couch_httpd_oauth, oauth_authentication_handler}, {couch_httpd_auth, default_authentication_handler}"}], + testFun + ); +}; diff --git a/share/www/script/test/security_validation.js b/share/www/script/test/security_validation.js index 10553e5e..52f895bd 100644 --- a/share/www/script/test/security_validation.js +++ b/share/www/script/test/security_validation.js @@ -39,14 +39,14 @@ couchTests.security_validation = function(debug) { run_on_modified_server( [{section: "httpd", - key: "authentication_handler", - value: "{couch_httpd, special_test_authentication_handler}"}, + key: "authentication_handlers", + value: "{couch_httpd_auth, special_test_authentication_handler}"}, {section:"httpd", key: "WWW-Authenticate", value: "X-Couch-Test-Auth"}], function () { - // try saving document usin the wrong credentials + // try saving document using the wrong credentials var wrongPasswordDb = new CouchDB("test_suite_db", {"WWW-Authenticate": "X-Couch-Test-Auth Damien Katz:foo"} ); @@ -59,8 +59,8 @@ couchTests.security_validation = function(debug) { T(wrongPasswordDb.last_req.status == 401); } - // test force_login=true. - var resp = wrongPasswordDb.request("GET", "/_whoami?force_login=true"); + // test force basic login + var resp = wrongPasswordDb.request("GET", "/_session?basic=true"); var err = JSON.parse(resp.responseText); T(err.error == "unauthorized"); T(resp.status == 401); @@ -104,7 +104,7 @@ couchTests.security_validation = function(debug) { T(userDb.save(designDoc).ok); // test the _whoami endpoint - var resp = userDb.request("GET", "/_whoami"); + var resp = userDb.request("GET", "/_session"); var user = JSON.parse(resp.responseText) T(user.name == "Damien Katz"); // test that the roles are listed properly diff --git a/share/www/script/test/show_documents.js b/share/www/script/test/show_documents.js index a1138596..c87cfb0b 100644 --- a/share/www/script/test/show_documents.js +++ b/share/www/script/test/show_documents.js @@ -335,7 +335,7 @@ couchTests.show_documents = function(debug) { var doc2 = {_id:"foo", a:2}; db.save(doc1); - //create the conflict with a all_or_nothing bulk docs request + // create the conflict with an all_or_nothing bulk docs request var docs = [doc2]; db.bulkSave(docs, {all_or_nothing:true}); diff --git a/src/couchdb/Makefile.am b/src/couchdb/Makefile.am index fce89f3c..ec707d64 100644 --- a/src/couchdb/Makefile.am +++ b/src/couchdb/Makefile.am @@ -61,6 +61,8 @@ source_files = \ couch_file.erl \ couch_httpd.erl \ couch_httpd_db.erl \ + couch_httpd_auth.erl \ + couch_httpd_oauth.erl \ couch_httpd_external.erl \ couch_httpd_show.erl \ couch_httpd_view.erl \ @@ -106,6 +108,8 @@ compiled_files = \ couch_file.beam \ couch_httpd.beam \ couch_httpd_db.beam \ + couch_httpd_auth.beam \ + couch_httpd_oauth.beam \ couch_httpd_external.beam \ couch_httpd_show.beam \ couch_httpd_view.beam \ diff --git a/src/couchdb/couch_db.hrl b/src/couchdb/couch_db.hrl index 4eda42e6..d4e0fbd5 100644 --- a/src/couchdb/couch_db.hrl +++ b/src/couchdb/couch_db.hrl @@ -67,7 +67,8 @@ db_url_handlers, user_ctx, req_body = undefined, - design_url_handlers + design_url_handlers, + auth }). diff --git a/src/couchdb/couch_httpd.erl b/src/couchdb/couch_httpd.erl index 616ada8c..766d18d0 100644 --- a/src/couchdb/couch_httpd.erl +++ b/src/couchdb/couch_httpd.erl @@ -23,8 +23,6 @@ -export([start_json_response/2, start_json_response/3, end_json_response/1]). -export([send_response/4,send_method_not_allowed/2,send_error/4, send_redirect/2,send_chunked_error/2]). -export([send_json/2,send_json/3,send_json/4]). --export([default_authentication_handler/1,special_test_authentication_handler/1]). --export([null_authentication_handler/1]). start_link() -> % read config and register for configuration changes @@ -111,6 +109,9 @@ make_arity_2_fun(SpecStr) -> fun(Arg1, Arg2) -> apply(Mod, Fun, [Arg1, Arg2]) end end. +% SpecStr is "{my_module, my_fun}, {my_module2, my_fun2}" +make_arity_1_fun_list(SpecStr) -> + [make_arity_1_fun(FunSpecStr) || FunSpecStr <- re:split(SpecStr, "(?<=})\\s*,\\s*(?={)", [{return, list}])]. stop() -> mochiweb_http:stop(?MODULE). @@ -119,8 +120,8 @@ stop() -> handle_request(MochiReq, DefaultFun, UrlHandlers, DbUrlHandlers, DesignUrlHandlers) -> Begin = now(), - AuthenticationFun = make_arity_1_fun( - couch_config:get("httpd", "authentication_handler")), + AuthenticationFuns = make_arity_1_fun_list( + couch_config:get("httpd", "authentication_handlers")), % for the path, use the raw path with the query string and fragment % removed, but URL quoting left intact RawUri = MochiReq:get(raw_path), @@ -171,11 +172,25 @@ handle_request(MochiReq, DefaultFun, {ok, Resp} = try - HandlerFun(HttpReq#httpd{user_ctx=AuthenticationFun(HttpReq)}) + % Try authentication handlers in order until one returns a result + case lists:foldl(fun(_Fun, #httpd{user_ctx=#user_ctx{}}=Req) -> Req; + (Fun, #httpd{}=Req) -> Fun(Req); + (_Fun, Response) -> Response + end, HttpReq, AuthenticationFuns) of + #httpd{user_ctx=#user_ctx{}}=Req -> HandlerFun(Req); + #httpd{}=Req -> + case couch_config:get("couch_httpd_auth", "require_valid_user", "false") of + "true" -> + throw({unauthorized, <<"Authentication required.">>}); + _ -> + HandlerFun(Req#httpd{user_ctx=#user_ctx{}}) + end; + Response -> Response + end catch throw:Error -> - % ?LOG_DEBUG("Minor error in HTTP request: ~p",[Error]), - % ?LOG_DEBUG("Stacktrace: ~p",[erlang:get_stacktrace()]), + ?LOG_DEBUG("Minor error in HTTP request: ~p",[Error]), + ?LOG_DEBUG("Stacktrace: ~p",[erlang:get_stacktrace()]), send_error(HttpReq, Error); error:badarg -> ?LOG_ERROR("Badarg error in HTTP request",[]), @@ -205,48 +220,6 @@ handle_request(MochiReq, DefaultFun, increment_method_stats(Method) -> couch_stats_collector:increment({httpd_request_methods, Method}). -special_test_authentication_handler(Req) -> - case header_value(Req, "WWW-Authenticate") of - "X-Couch-Test-Auth " ++ NamePass -> - % NamePass is a colon separated string: "joe schmoe:a password". - {ok, [Name, Pass]} = regexp:split(NamePass, ":"), - case {Name, Pass} of - {"Jan Lehnardt", "apple"} -> ok; - {"Christopher Lenz", "dog food"} -> ok; - {"Noah Slater", "biggiesmalls endian"} -> ok; - {"Chris Anderson", "mp3"} -> ok; - {"Damien Katz", "pecan pie"} -> ok; - {_, _} -> - throw({unauthorized, <<"Name or password is incorrect.">>}) - end, - #user_ctx{name=?l2b(Name)}; - _ -> - % No X-Couch-Test-Auth credentials sent, give admin access so the - % previous authentication can be restored after the test - #user_ctx{roles=[<<"_admin">>]} - end. - -default_authentication_handler(Req) -> - case basic_username_pw(Req) of - {User, Pass} -> - case couch_server:is_admin(User, Pass) of - true -> - #user_ctx{name=?l2b(User), roles=[<<"_admin">>]}; - false -> - throw({unauthorized, <<"Name or password is incorrect.">>}) - end; - nil -> - case couch_server:has_admins() of - true -> - #user_ctx{}; - false -> - % if no admins, then everyone is admin! Yay, admin party! - #user_ctx{roles=[<<"_admin">>]} - end - end. - -null_authentication_handler(_Req) -> - #user_ctx{roles=[<<"_admin">>]}. % Utilities @@ -265,8 +238,9 @@ header_value(#httpd{mochi_req=MochiReq}, Key, Default) -> primary_header_value(#httpd{mochi_req=MochiReq}, Key) -> MochiReq:get_primary_header_value(Key). -serve_file(#httpd{mochi_req=MochiReq}, RelativePath, DocumentRoot) -> - {ok, MochiReq:serve_file(RelativePath, DocumentRoot, server_header())}. +serve_file(#httpd{mochi_req=MochiReq}=Req, RelativePath, DocumentRoot) -> + {ok, MochiReq:serve_file(RelativePath, DocumentRoot, + server_header() ++ couch_httpd_auth:cookie_auth_header(Req, []))}. qs_value(Req, Key) -> qs_value(Req, Key, undefined). @@ -307,11 +281,16 @@ recv_chunked(#httpd{mochi_req=MochiReq}, MaxChunkSize, ChunkFun, InitState) -> % called with Length == 0 on the last time. MochiReq:stream_body(MaxChunkSize, ChunkFun, InitState). -body(#httpd{mochi_req=MochiReq}) -> - % Maximum size of document PUT request body (4GB) - MaxSize = list_to_integer( - couch_config:get("couchdb", "max_document_size", "4294967296")), - MochiReq:recv_body(MaxSize). +body(#httpd{mochi_req=MochiReq, req_body=ReqBody}) -> + case ReqBody of + undefined -> + % Maximum size of document PUT request body (4GB) + MaxSize = list_to_integer( + couch_config:get("couchdb", "max_document_size", "4294967296")), + MochiReq:recv_body(MaxSize); + _Else -> + ReqBody + end. json_body(Httpd) -> ?JSON_DECODE(body(Httpd)). @@ -357,37 +336,21 @@ verify_is_server_admin(#httpd{user_ctx=#user_ctx{roles=Roles}}) -> -basic_username_pw(Req) -> - case header_value(Req, "Authorization") of - "Basic " ++ Base64Value -> - case string:tokens(?b2l(couch_util:decodeBase64(Base64Value)),":") of - [User, Pass] -> - {User, Pass}; - [User] -> - {User, ""}; - _ -> - nil - end; - _ -> - nil - end. - - -start_chunked_response(#httpd{mochi_req=MochiReq}, Code, Headers) -> +start_chunked_response(#httpd{mochi_req=MochiReq}=Req, Code, Headers) -> couch_stats_collector:increment({httpd_status_codes, Code}), - {ok, MochiReq:respond({Code, Headers ++ server_header(), chunked})}. + {ok, MochiReq:respond({Code, Headers ++ server_header() ++ couch_httpd_auth:cookie_auth_header(Req, Headers), chunked})}. send_chunk(Resp, Data) -> Resp:write_chunk(Data), {ok, Resp}. -send_response(#httpd{mochi_req=MochiReq}, Code, Headers, Body) -> +send_response(#httpd{mochi_req=MochiReq}=Req, Code, Headers, Body) -> couch_stats_collector:increment({httpd_status_codes, Code}), if Code >= 400 -> ?LOG_DEBUG("httpd ~p error response:~n ~s", [Code, Body]); true -> ok end, - {ok, MochiReq:respond({Code, Headers ++ server_header(), Body})}. + {ok, MochiReq:respond({Code, Headers ++ server_header() ++ couch_httpd_auth:cookie_auth_header(Req, Headers), Body})}. send_method_not_allowed(Req, Methods) -> send_response(Req, 405, [{"Allow", Methods}], <<>>). @@ -508,17 +471,22 @@ error_info(Error) -> send_error(_Req, {already_sent, Resp, _Error}) -> {ok, Resp}; -send_error(Req, Error) -> +send_error(#httpd{mochi_req=MochiReq}=Req, Error) -> {Code, ErrorStr, ReasonStr} = error_info(Error), - if Code == 401 -> - case couch_config:get("httpd", "WWW-Authenticate", nil) of - nil -> - Headers = []; + Headers = if Code == 401 -> + case MochiReq:get_header_value("X-CouchDB-WWW-Authenticate") of + undefined -> + case couch_config:get("httpd", "WWW-Authenticate", nil) of + nil -> + []; + Type -> + [{"WWW-Authenticate", Type}] + end; Type -> - Headers = [{"WWW-Authenticate", Type}] + [{"WWW-Authenticate", Type}] end; true -> - Headers = [] + [] end, send_error(Req, Code, Headers, ErrorStr, ReasonStr). diff --git a/src/couchdb/couch_httpd_auth.erl b/src/couchdb/couch_httpd_auth.erl new file mode 100644 index 00000000..29a50eb5 --- /dev/null +++ b/src/couchdb/couch_httpd_auth.erl @@ -0,0 +1,507 @@ +% Licensed under the Apache License, Version 2.0 (the "License"); you may not +% use this file except in compliance with the License. You may obtain a copy of +% the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +% License for the specific language governing permissions and limitations under +% the License. + +-module(couch_httpd_auth). +-include("couch_db.hrl"). + +-export([default_authentication_handler/1,special_test_authentication_handler/1]). +-export([cookie_authentication_handler/1]). +-export([null_authentication_handler/1]). +-export([cookie_auth_header/2]). +-export([handle_session_req/1]). +-export([handle_user_req/1]). +-export([ensure_users_db_exists/1, get_user/2]). + +-import(couch_httpd, [header_value/2, send_json/2,send_json/4, send_method_not_allowed/2]). + +special_test_authentication_handler(Req) -> + case header_value(Req, "WWW-Authenticate") of + "X-Couch-Test-Auth " ++ NamePass -> + % NamePass is a colon separated string: "joe schmoe:a password". + [Name, Pass] = re:split(NamePass, ":", [{return, list}]), + case {Name, Pass} of + {"Jan Lehnardt", "apple"} -> ok; + {"Christopher Lenz", "dog food"} -> ok; + {"Noah Slater", "biggiesmalls endian"} -> ok; + {"Chris Anderson", "mp3"} -> ok; + {"Damien Katz", "pecan pie"} -> ok; + {_, _} -> + throw({unauthorized, <<"Name or password is incorrect.">>}) + end, + Req#httpd{user_ctx=#user_ctx{name=?l2b(Name)}}; + _ -> + % No X-Couch-Test-Auth credentials sent, give admin access so the + % previous authentication can be restored after the test + Req#httpd{user_ctx=#user_ctx{roles=[<<"_admin">>]}} + end. + +basic_username_pw(Req) -> + case header_value(Req, "Authorization") of + "Basic " ++ Base64Value -> + case string:tokens(?b2l(couch_util:decodeBase64(Base64Value)),":") of + [User, Pass] -> + {User, Pass}; + [User] -> + {User, ""}; + _ -> + nil + end; + _ -> + nil + end. + +default_authentication_handler(Req) -> + case basic_username_pw(Req) of + {User, Pass} -> + case couch_server:is_admin(User, Pass) of + true -> + Req#httpd{user_ctx=#user_ctx{name=?l2b(User), roles=[<<"_admin">>]}}; + false -> + throw({unauthorized, <<"Name or password is incorrect.">>}) + end; + nil -> + case couch_server:has_admins() of + true -> + Req; + false -> + case couch_config:get("couch_httpd_auth", "require_valid_user", "false") of + "true" -> Req; + % If no admins, and no user required, then everyone is admin! + % Yay, admin party! + _ -> Req#httpd{user_ctx=#user_ctx{roles=[<<"_admin">>]}} + end + end + end. + +null_authentication_handler(Req) -> + Req#httpd{user_ctx=#user_ctx{roles=[<<"_admin">>]}}. + +% Cookie auth handler using per-node user db +cookie_authentication_handler(Req) -> + DbName = couch_config:get("couch_httpd_auth", "authentication_db"), + case cookie_auth_user(Req, ?l2b(DbName)) of + % Fall back to default authentication handler + nil -> default_authentication_handler(Req); + Req2 -> Req2 + end. + +% Cookie auth handler using per-db user db +% cookie_authentication_handler(#httpd{path_parts=Path}=Req) -> +% case Path of +% [DbName|_] -> +% case cookie_auth_user(Req, DbName) of +% nil -> default_authentication_handler(Req); +% Req2 -> Req2 +% end; +% _Else -> +% % Fall back to default authentication handler +% default_authentication_handler(Req) +% end. + +% maybe we can use hovercraft to simplify running this view query +get_user(Db, UserName) -> + DesignId = <<"_design/_auth">>, + ViewName = <<"users">>, + % if the design doc or the view doesn't exist, then make it + ensure_users_view_exists(Db, DesignId, ViewName), + + case (catch couch_view:get_map_view(Db, DesignId, ViewName, nil)) of + {ok, View, _Group} -> + FoldlFun = fun + ({{Key, _DocId}, Value}, _, nil) when Key == UserName -> {ok, Value}; + (_, _, Acc) -> {stop, Acc} + end, + case couch_view:fold(View, {UserName, nil}, fwd, FoldlFun, nil) of + {ok, {Result}} -> Result; + _Else -> nil + end; + {not_found, _Reason} -> + nil + % case (catch couch_view:get_reduce_view(Db, DesignId, ViewName, nil)) of + % {ok, _ReduceView, _Group} -> + % not_implemented; + % {not_found, _Reason} -> + % nil + % end + end. + +ensure_users_db_exists(DbName) -> + case couch_db:open(DbName, [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of + {ok, Db} -> + couch_db:close(Db), + ok; + _Error -> + ?LOG_ERROR("Create the db ~p", [DbName]), + {ok, Db} = couch_db:create(DbName, [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]), + ?LOG_ERROR("Created the db ~p", [DbName]), + couch_db:close(Db), + ok + end. + +ensure_users_view_exists(Db, DDocId, VName) -> + try couch_httpd_db:couch_doc_open(Db, DDocId, nil, []) of + _Foo -> ok + catch + _:Error -> + ?LOG_ERROR("create the design document ~p : ~p", [DDocId, Error]), + % create the design document + {ok, AuthDesign} = auth_design_doc(DDocId, VName), + {ok, _Rev} = couch_db:update_doc(Db, AuthDesign, []), + ?LOG_ERROR("created the design document", []), + ok + end. + +auth_design_doc(DocId, VName) -> + DocProps = [ + {<<"_id">>, DocId}, + {<<"language">>,<<"javascript">>}, + {<<"views">>, + {[{VName, + {[{<<"map">>, + <<"function (doc) {\n if (doc.type == \"user\") {\n emit(doc.username, doc);\n}\n}">> + }]} + }]} + }], + {ok, couch_doc:from_json_obj({DocProps})}. + + +user_doc(DocId, Username, UserSalt, PasswordHash, Email, Active, Roles) -> + user_doc(DocId, Username, UserSalt, PasswordHash, Email, Active, Roles, nil). +user_doc(DocId, Username, UserSalt, PasswordHash, Email, Active, Roles, Rev) -> + DocProps = [ + {<<"_id">>, DocId}, + {<<"type">>, <<"user">>}, + {<<"username">>, Username}, + {<<"password_sha">>, PasswordHash}, + {<<"salt">>, UserSalt}, + {<<"email">>, Email}, + {<<"active">>, Active}, + {<<"roles">>, Roles}], + DocProps1 = case Rev of + nil -> DocProps; + _Rev -> + [{<<"_rev">>, Rev}] ++ DocProps + end, + {ok, couch_doc:from_json_obj({DocProps1})}. + +cookie_auth_user(_Req, undefined) -> nil; +cookie_auth_user(#httpd{mochi_req=MochiReq}=Req, DbName) -> + case MochiReq:get_cookie_value("AuthSession") of + undefined -> nil; + [] -> nil; + Cookie -> + case couch_db:open(DbName, [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of + {ok, Db} -> + try + AuthSession = couch_util:decodeBase64Url(Cookie), + [User, TimeStr | HashParts] = string:tokens(?b2l(AuthSession), ":"), + % Verify expiry and hash + {NowMS, NowS, _} = erlang:now(), + CurrentTime = NowMS * 1000000 + NowS, + case couch_config:get("couch_httpd_auth", "secret", nil) of + nil -> nil; + SecretStr -> + Secret = ?l2b(SecretStr), + case get_user(Db, ?l2b(User)) of + nil -> nil; + Result -> + UserSalt = proplists:get_value(<<"salt">>, Result, <<"">>), + FullSecret = <<Secret/binary, UserSalt/binary>>, + ExpectedHash = crypto:sha_mac(FullSecret, User ++ ":" ++ TimeStr), + Hash = ?l2b(string:join(HashParts, ":")), + Timeout = to_int(couch_config:get("couch_httpd_auth", "timeout", 600)), + ?LOG_DEBUG("timeout ~p", [Timeout]), + case (catch erlang:list_to_integer(TimeStr, 16)) of + TimeStamp when CurrentTime < TimeStamp + Timeout + andalso ExpectedHash == Hash -> + TimeLeft = TimeStamp + Timeout - CurrentTime, + ?LOG_DEBUG("Successful cookie auth as: ~p", [User]), + Req#httpd{user_ctx=#user_ctx{ + name=?l2b(User), + roles=proplists:get_value(<<"roles">>, Result, []) + }, auth={FullSecret, TimeLeft < Timeout*0.9}}; + _Else -> + nil + end + end + end + after + couch_db:close(Db) + end; + _Else -> + nil + end + end. + +cookie_auth_header(#httpd{user_ctx=#user_ctx{name=null}}, _Headers) -> []; +cookie_auth_header(#httpd{user_ctx=#user_ctx{name=User}, auth={Secret, true}}, Headers) -> + % Note: we only set the AuthSession cookie if: + % * a valid AuthSession cookie has been received + % * we are outside a 10% timeout window + % * and if an AuthSession cookie hasn't already been set e.g. by a login + % or logout handler. + % The login and logout handlers need to set the AuthSession cookie + % themselves. + case proplists:get_value("Set-Cookie", Headers) of + undefined -> []; + Cookie -> + case proplists:get_value("AuthSession", + mochiweb_cookies:parse_cookie(Cookie), undefined) of + undefined -> + {NowMS, NowS, _} = erlang:now(), + TimeStamp = NowMS * 1000000 + NowS, + [cookie_auth_cookie(?b2l(User), Secret, TimeStamp)]; + _Else -> [] + end + end; +cookie_auth_header(_Req, _Headers) -> []. + +cookie_auth_cookie(User, Secret, TimeStamp) -> + SessionData = User ++ ":" ++ erlang:integer_to_list(TimeStamp, 16), + Hash = crypto:sha_mac(Secret, SessionData), + mochiweb_cookies:cookie("AuthSession", + couch_util:encodeBase64Url(SessionData ++ ":" ++ ?b2l(Hash)), + [{path, "/"}, {http_only, true}]). % TODO add {secure, true} when SSL is detected + +% Login handler with user db +handle_login_req(#httpd{method='POST', mochi_req=MochiReq}=Req, #db{}=Db) -> + ReqBody = MochiReq:recv_body(), + Form = case MochiReq:get_primary_header_value("content-type") of + "application/x-www-form-urlencoded" ++ _ -> + mochiweb_util:parse_qs(ReqBody); + _ -> + [] + end, + UserName = ?l2b(proplists:get_value("username", Form, "")), + Password = ?l2b(proplists:get_value("password", Form, "")), + User = case get_user(Db, UserName) of + nil -> []; + Result -> Result + end, + UserSalt = proplists:get_value(<<"salt">>, User, <<>>), + PasswordHash = couch_util:encodeBase64(crypto:sha(<<UserSalt/binary, Password/binary>>)), + case proplists:get_value(<<"password_sha">>, User, nil) of + ExpectedHash when ExpectedHash == PasswordHash -> + Secret = ?l2b(couch_config:get("couch_httpd_auth", "secret", nil)), + {NowMS, NowS, _} = erlang:now(), + CurrentTime = NowMS * 1000000 + NowS, + Cookie = cookie_auth_cookie(?b2l(UserName), <<Secret/binary, UserSalt/binary>>, CurrentTime), + {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of + nil -> + {200, [Cookie]}; + Redirect -> + {302, [Cookie, {"Location", couch_httpd:absolute_uri(Req, Redirect)}]} + end, + send_json(Req#httpd{req_body=ReqBody}, Code, Headers, + {[{ok, true}]}); + _Else -> + throw({unauthorized, <<"Name or password is incorrect.">>}) + end. + +% Session Handler + +handle_session_req(#httpd{method='POST'}=Req) -> + % login + DbName = couch_config:get("couch_httpd_auth", "authentication_db"), + case couch_db:open(?l2b(DbName), [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of + {ok, Db} -> handle_login_req(Req, Db) + end; +handle_session_req(#httpd{method='GET', user_ctx=UserCtx}=Req) -> + % whoami + Name = UserCtx#user_ctx.name, + Roles = UserCtx#user_ctx.roles, + ForceLogin = couch_httpd:qs_value(Req, "basic", "false"), + case {Name, ForceLogin} of + {null, "true"} -> + throw({unauthorized, <<"Please login.">>}); + _False -> ok + end, + send_json(Req, {[ + {ok, true}, + {name, Name}, + {roles, Roles} + ]}); +handle_session_req(#httpd{method='DELETE'}=Req) -> + % logout + Cookie = mochiweb_cookies:cookie("AuthSession", "", [{path, "/"}, {http_only, true}]), + {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of + nil -> + {200, [Cookie]}; + Redirect -> + {302, [Cookie, {"Location", couch_httpd:absolute_uri(Req, Redirect)}]} + end, + send_json(Req, Code, Headers, {[{ok, true}]}); +handle_session_req(Req) -> + send_method_not_allowed(Req, "GET,HEAD,POST,DELETE"). + +create_user_req(#httpd{method='POST', mochi_req=MochiReq}=Req, Db) -> + ReqBody = MochiReq:recv_body(), + Form = case MochiReq:get_primary_header_value("content-type") of + "application/x-www-form-urlencoded" ++ _ -> + ?LOG_INFO("body parsed ~p", [mochiweb_util:parse_qs(ReqBody)]), + mochiweb_util:parse_qs(ReqBody); + _ -> + [] + end, + Roles = proplists:get_all_values("roles", Form), + UserName = ?l2b(proplists:get_value("username", Form, "")), + Password = ?l2b(proplists:get_value("password", Form, "")), + Email = ?l2b(proplists:get_value("email", Form, "")), + Active = couch_httpd_view:parse_bool_param(proplists:get_value("active", Form, "true")), + case get_user(Db, UserName) of + nil -> + Roles1 = case Roles of + [] -> Roles; + _ -> + ok = couch_httpd:verify_is_server_admin(Req), + [?l2b(R) || R <- Roles] + end, + + UserSalt = couch_util:new_uuid(), + PasswordHash = couch_util:encodeBase64(crypto:sha(<<UserSalt/binary, Password/binary>>)), + DocId = couch_util:new_uuid(), + {ok, UserDoc} = user_doc(DocId, UserName, UserSalt, PasswordHash, Email, Active, Roles1), + {ok, _Rev} = couch_db:update_doc(Db, UserDoc, []), + ?LOG_DEBUG("User ~s (~s) with password, ~s created.", [?b2l(UserName), ?b2l(DocId), ?b2l(Password)]), + {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of + nil -> + {200, []}; + Redirect -> + {302, [{"Location", couch_httpd:absolute_uri(Req, Redirect)}]} + end, + send_json(Req, Code, Headers, {[{ok, true}]}); + _Result -> + ?LOG_DEBUG("Can't create ~s: already exists", [?b2l(UserName)]), + throw({forbidden, <<"User already exists.">>}) + end. + +update_user_req(#httpd{method='PUT', mochi_req=MochiReq, user_ctx=UserCtx}=Req, Db, UserName) -> + Name = UserCtx#user_ctx.name, + UserRoles = UserCtx#user_ctx.roles, + case User = get_user(Db, UserName) of + nil -> + throw({not_found, <<"User don't exist">>}); + _Result -> + ReqBody = MochiReq:recv_body(), + Form = case MochiReq:get_primary_header_value("content-type") of + "application/x-www-form-urlencoded" ++ _ -> + mochiweb_util:parse_qs(ReqBody); + _ -> + [] + end, + Roles = proplists:get_all_values("roles", Form), + Password = ?l2b(proplists:get_value("password", Form, "")), + Email = ?l2b(proplists:get_value("email", Form, "")), + Active = couch_httpd_view:parse_bool_param(proplists:get_value("active", Form, "true")), + OldPassword = proplists:get_value("old_password", Form, ""), + OldPassword1 = ?l2b(OldPassword), + UserSalt = proplists:get_value(<<"salt">>, User, <<>>), + OldRev = proplists:get_value(<<"_rev">>, User, <<>>), + DocId = proplists:get_value(<<"_id">>, User, <<>>), + CurrentPasswordHash = proplists:get_value(<<"password_sha">>, User, nil), + + + Roles1 = case Roles of + [] -> Roles; + _ -> + ok = couch_httpd:verify_is_server_admin(Req), + [?l2b(R) || R <- Roles] + end, + + PasswordHash = case lists:member(<<"_admin">>, UserRoles) of + true -> + Hash = case Password of + <<>> -> CurrentPasswordHash; + _Else -> + H = couch_util:encodeBase64(crypto:sha(<<UserSalt/binary, Password/binary>>)), + H + end, + Hash; + false when Name == UserName -> + %% for user we test old password before allowing change + Hash = case Password of + <<>> -> + CurrentPasswordHash; + _P when length(OldPassword) == 0 -> + throw({forbidden, <<"Old password is incorrect.">>}); + _Else -> + OldPasswordHash = couch_util:encodeBase64(crypto:sha(<<UserSalt/binary, OldPassword1/binary>>)), + ?LOG_DEBUG("~p == ~p", [CurrentPasswordHash, OldPasswordHash]), + Hash1 = case CurrentPasswordHash of + ExpectedHash when ExpectedHash == OldPasswordHash -> + H = couch_util:encodeBase64(crypto:sha(<<UserSalt/binary, Password/binary>>)), + H; + _ -> + throw({forbidden, <<"Old password is incorrect.">>}) + end, + Hash1 + end, + Hash; + _ -> + throw({forbidden, <<"You aren't allowed to change this password.">>}) + end, + {ok, UserDoc} = user_doc(DocId, UserName, UserSalt, PasswordHash, Email, Active, Roles1, OldRev), + {ok, _Rev} = couch_db:update_doc(Db, UserDoc, []), + ?LOG_DEBUG("User ~s (~s)updated.", [?b2l(UserName), ?b2l(DocId)]), + {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of + nil -> {200, []}; + Redirect -> + {302, [{"Location", couch_httpd:absolute_uri(Req, Redirect)}]} + end, + send_json(Req, Code, Headers, {[{ok, true}]}) + end. + +handle_user_req(#httpd{method='POST'}=Req) -> + DbName = couch_config:get("couch_httpd_auth", "authentication_db"), + ensure_users_db_exists(?l2b(DbName)), + case couch_db:open(?l2b(DbName), [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of + {ok, Db} -> create_user_req(Req, Db) + end; +handle_user_req(#httpd{method='PUT', path_parts=[_]}=_Req) -> + throw({bad_request, <<"Username is missing">>}); +handle_user_req(#httpd{method='PUT', path_parts=[_, UserName]}=Req) -> + DbName = couch_config:get("couch_httpd_auth", "authentication_db"), + ensure_users_db_exists(?l2b(DbName)), + case couch_db:open(?l2b(DbName), [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of + {ok, Db} -> update_user_req(Req, Db, UserName) + end; +handle_user_req(Req) -> + send_method_not_allowed(Req, "GET,HEAD,POST,PUT,DELETE"). + +to_int(Value) when is_binary(Value) -> + to_int(?b2l(Value)); +to_int(Value) when is_list(Value) -> + erlang:list_to_integer(Value); +to_int(Value) when is_integer(Value) -> + Value. + +% % Login handler +% handle_login_req(#httpd{method='POST'}=Req) -> +% DbName = couch_config:get("couch_httpd_auth", "authentication_db"), +% case couch_db:open(?l2b(DbName), [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of +% {ok, Db} -> handle_login_req(Req, Db) +% end; +% handle_login_req(Req) -> +% send_method_not_allowed(Req, "POST"). +% +% % Logout handler +% handle_logout_req(#httpd{method='POST'}=Req) -> +% Cookie = mochiweb_cookies:cookie("AuthSession", "", [{path, "/"}, {http_only, true}]), +% {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of +% nil -> +% {200, [Cookie]}; +% Redirect -> +% {302, [Cookie, {"Location", couch_httpd:absolute_uri(Req, Redirect)}]} +% end, +% send_json(Req, Code, Headers, {[{ok, true}]}); +% handle_logout_req(Req) -> +% send_method_not_allowed(Req, "POST"). diff --git a/src/couchdb/couch_httpd_external.erl b/src/couchdb/couch_httpd_external.erl index 4ff01d9e..d6fa945f 100644 --- a/src/couchdb/couch_httpd_external.erl +++ b/src/couchdb/couch_httpd_external.erl @@ -57,7 +57,8 @@ process_external_req(HttpReq, Db, Name) -> json_req_obj(#httpd{mochi_req=Req, method=Verb, path_parts=Path, - req_body=ReqBody + req_body=ReqBody, + user_ctx=#user_ctx{name=UserName, roles=UserRoles} }, Db) -> Body = case ReqBody of undefined -> Req:recv_body(); @@ -69,6 +70,7 @@ json_req_obj(#httpd{mochi_req=Req, _ -> [] end, + UserCtx = {[{<<"name">>, UserName}, {<<"roles">>, UserRoles}]}, Headers = Req:get(headers), Hlist = mochiweb_headers:to_list(Headers), {ok, Info} = couch_db:get_db_info(Db), @@ -80,7 +82,8 @@ json_req_obj(#httpd{mochi_req=Req, {<<"headers">>, to_json_terms(Hlist)}, {<<"body">>, Body}, {<<"form">>, to_json_terms(ParsedForm)}, - {<<"cookie">>, to_json_terms(Req:parse_cookie())}]}. + {<<"cookie">>, to_json_terms(Req:parse_cookie())}, + {<<"userCtx">>, UserCtx}]}. to_json_terms(Data) -> to_json_terms(Data, []). diff --git a/src/couchdb/couch_httpd_misc_handlers.erl b/src/couchdb/couch_httpd_misc_handlers.erl index b811f05d..eea353bd 100644 --- a/src/couchdb/couch_httpd_misc_handlers.erl +++ b/src/couchdb/couch_httpd_misc_handlers.erl @@ -15,7 +15,7 @@ -export([handle_welcome_req/2,handle_favicon_req/2,handle_utils_dir_req/2, handle_all_dbs_req/1,handle_replicate_req/1,handle_restart_req/1, handle_uuids_req/1,handle_config_req/1,handle_log_req/1, - handle_task_status_req/1,handle_sleep_req/1,handle_whoami_req/1]). + handle_task_status_req/1,handle_sleep_req/1]). -export([increment_update_seq_req/2]). @@ -88,11 +88,13 @@ fix_db_url(UrlBin) -> get_rep_endpoint(_Req, {Props}) -> Url = proplists:get_value(<<"url">>, Props), {BinHeaders} = proplists:get_value(<<"headers">>, Props, {[]}), - {remote, fix_db_url(Url), [{?b2l(K),?b2l(V)} || {K,V} <- BinHeaders]}; + Auth = proplists:get_value(<<"auth">>, Props, undefined), + ?LOG_DEBUG("AUTH ~p", [Auth]), + {remote, fix_db_url(Url), [{?b2l(K),?b2l(V)} || {K,V} <- BinHeaders], Auth}; get_rep_endpoint(_Req, <<"http://",_/binary>>=Url) -> - {remote, fix_db_url(Url), []}; + {remote, fix_db_url(Url), [], []}; get_rep_endpoint(_Req, <<"https://",_/binary>>=Url) -> - {remote, fix_db_url(Url), []}; + {remote, fix_db_url(Url), [], []}; get_rep_endpoint(#httpd{user_ctx=UserCtx}, <<DbName/binary>>) -> {local, DbName, UserCtx}. @@ -218,20 +220,3 @@ handle_log_req(Req) -> send_method_not_allowed(Req, "GET"). -% whoami handler -handle_whoami_req(#httpd{method='GET', user_ctx=UserCtx}=Req) -> - Name = UserCtx#user_ctx.name, - Roles = UserCtx#user_ctx.roles, - ForceLogin = couch_httpd:qs_value(Req, "force_login", "false"), - case {Name, ForceLogin} of - {null, "true"} -> - throw({unauthorized, <<"Please login.">>}); - _False -> ok - end, - send_json(Req, {[ - {ok, true}, - {name, Name}, - {roles, Roles} - ]}); -handle_whoami_req(Req) -> - send_method_not_allowed(Req, "GET"). diff --git a/src/couchdb/couch_httpd_oauth.erl b/src/couchdb/couch_httpd_oauth.erl new file mode 100644 index 00000000..f542a989 --- /dev/null +++ b/src/couchdb/couch_httpd_oauth.erl @@ -0,0 +1,173 @@ +% Licensed under the Apache License, Version 2.0 (the "License"); you may not +% use this file except in compliance with the License. You may obtain a copy of +% the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +% License for the specific language governing permissions and limitations under +% the License. + +-module(couch_httpd_oauth). +-include("couch_db.hrl"). + +-export([oauth_authentication_handler/1, handle_oauth_req/1, consumer_lookup/2]). + +% OAuth auth handler using per-node user db +oauth_authentication_handler(#httpd{method=Method}=Req) -> + serve_oauth(Req, fun(URL, Params, Consumer, Signature) -> + AccessToken = proplists:get_value("oauth_token", Params), + TokenSecret = couch_config:get("oauth_token_secrets", AccessToken), + case oauth:verify(Signature, atom_to_list(Method), URL, Params, Consumer, TokenSecret) of + true -> + set_user_ctx(Req, AccessToken); + false -> + Req + end + end, true). + +% Look up the consumer key and get the roles to give the consumer +set_user_ctx(Req, AccessToken) -> + DbName = couch_config:get("couch_httpd_auth", "authentication_db"), + couch_httpd_auth:ensure_users_db_exists(?l2b(DbName)), + case couch_db:open(?l2b(DbName), [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of + {ok, Db} -> + Name = ?l2b(couch_config:get("oauth_token_users", AccessToken)), + case couch_httpd_auth:get_user(Db, Name) of + nil -> Req; + User -> + Roles = proplists:get_value(<<"roles">>, User, []), + Req#httpd{user_ctx=#user_ctx{name=Name, roles=Roles}} + end; + _Else-> + Req + end. + +% OAuth request_token +handle_oauth_req(#httpd{path_parts=[_OAuth, <<"request_token">>], method=Method}=Req) -> + serve_oauth(Req, fun(URL, Params, Consumer, Signature) -> + AccessToken = proplists:get_value("oauth_token", Params), + TokenSecret = couch_config:get("oauth_token_secrets", AccessToken), + case oauth:verify(Signature, atom_to_list(Method), URL, Params, Consumer, TokenSecret) of + true -> + ok(Req, <<"oauth_token=requestkey&oauth_token_secret=requestsecret">>); + false -> + invalid_signature(Req) + end + end, false); +handle_oauth_req(#httpd{path_parts=[_OAuth, <<"authorize">>]}=Req) -> + {ok, serve_oauth_authorize(Req)}; +handle_oauth_req(#httpd{path_parts=[_OAuth, <<"access_token">>], method='GET'}=Req) -> + serve_oauth(Req, fun(URL, Params, Consumer, Signature) -> + case oauth:token(Params) of + "requestkey" -> + case oauth:verify(Signature, "GET", URL, Params, Consumer, "requestsecret") of + true -> + ok(Req, <<"oauth_token=accesskey&oauth_token_secret=accesssecret">>); + false -> + invalid_signature(Req) + end; + _ -> + couch_httpd:send_error(Req, 400, <<"invalid_token">>, <<"Invalid OAuth token.">>) + end + end, false); +handle_oauth_req(#httpd{path_parts=[_OAuth, <<"access_token">>]}=Req) -> + couch_httpd:send_method_not_allowed(Req, "GET"). + +invalid_signature(Req) -> + couch_httpd:send_error(Req, 400, <<"invalid_signature">>, <<"Invalid signature value.">>). + +% This needs to be protected i.e. force user to login using HTTP Basic Auth or form-based login. +serve_oauth_authorize(#httpd{method=Method}=Req) -> + case Method of + 'GET' -> + % Confirm with the User that they want to authenticate the Consumer + serve_oauth(Req, fun(URL, Params, Consumer, Signature) -> + AccessToken = proplists:get_value("oauth_token", Params), + TokenSecret = couch_config:get("oauth_token_secrets", AccessToken), + case oauth:verify(Signature, "GET", URL, Params, Consumer, TokenSecret) of + true -> + ok(Req, <<"oauth_token=requestkey&oauth_token_secret=requestsecret">>); + false -> + invalid_signature(Req) + end + end, false); + 'POST' -> + % If the User has confirmed, we direct the User back to the Consumer with a verification code + serve_oauth(Req, fun(URL, Params, Consumer, Signature) -> + AccessToken = proplists:get_value("oauth_token", Params), + TokenSecret = couch_config:get("oauth_token_secrets", AccessToken), + case oauth:verify(Signature, "POST", URL, Params, Consumer, TokenSecret) of + true -> + %redirect(oauth_callback, oauth_token, oauth_verifier), + ok(Req, <<"oauth_token=requestkey&oauth_token_secret=requestsecret">>); + false -> + invalid_signature(Req) + end + end, false); + _ -> + couch_httpd:send_method_not_allowed(Req, "GET,POST") + end. + +serve_oauth(#httpd{mochi_req=MochiReq}=Req, Fun, FailSilently) -> + % 1. In the HTTP Authorization header as defined in OAuth HTTP Authorization Scheme. + % 2. As the HTTP POST request body with a content-type of application/x-www-form-urlencoded. + % 3. Added to the URLs in the query part (as defined by [RFC3986] section 3). + AuthHeader = case MochiReq:get_header_value("authorization") of + undefined -> + ""; + Else -> + [Head | Tail] = re:split(Else, "\\s", [{parts, 2}, {return, list}]), + case [string:to_lower(Head) | Tail] of + ["oauth", Rest] -> Rest; + _ -> "" + end + end, + HeaderParams = oauth_uri:params_from_header_string(AuthHeader), + %Realm = proplists:get_value("realm", HeaderParams), + Params = proplists:delete("realm", HeaderParams) ++ MochiReq:parse_qs(), + ?LOG_DEBUG("OAuth Params: ~p", [Params]), + case proplists:get_value("oauth_version", Params, "1.0") of + "1.0" -> + case proplists:get_value("oauth_consumer_key", Params, undefined) of + undefined -> + case FailSilently of + true -> Req; + false -> couch_httpd:send_error(Req, 400, <<"invalid_consumer">>, <<"Invalid consumer.">>) + end; + ConsumerKey -> + SigMethod = proplists:get_value("oauth_signature_method", Params), + case consumer_lookup(ConsumerKey, SigMethod) of + none -> + couch_httpd:send_error(Req, 400, <<"invalid_consumer">>, <<"Invalid consumer (key or signature method).">>); + Consumer -> + Signature = proplists:get_value("oauth_signature", Params), + URL = couch_httpd:absolute_uri(Req, MochiReq:get(path)), + Fun(URL, proplists:delete("oauth_signature", Params), + Consumer, Signature) + end + end; + _ -> + couch_httpd:send_error(Req, 400, <<"invalid_oauth_version">>, <<"Invalid OAuth version.">>) + end. + +consumer_lookup(Key, MethodStr) -> + SignatureMethod = case MethodStr of + "PLAINTEXT" -> plaintext; + "HMAC-SHA1" -> hmac_sha1; + %"RSA-SHA1" -> rsa_sha1; + _Else -> undefined + end, + case SignatureMethod of + undefined -> none; + _SupportedMethod -> + case couch_config:get("oauth_consumer_secrets", Key, undefined) of + undefined -> none; + Secret -> {Key, Secret, SignatureMethod} + end + end. + +ok(#httpd{mochi_req=MochiReq}, Body) -> + {ok, MochiReq:respond({200, [], Body})}. diff --git a/src/couchdb/couch_httpd_show.erl b/src/couchdb/couch_httpd_show.erl index 40f0dc11..1428e612 100644 --- a/src/couchdb/couch_httpd_show.erl +++ b/src/couchdb/couch_httpd_show.erl @@ -30,7 +30,6 @@ handle_doc_show_req(#httpd{ handle_doc_show(Req, DesignName, ShowName, DocId, Db); handle_doc_show_req(#httpd{ - method='GET', path_parts=[_DbName, _Design, DesignName, _Show, ShowName] }=Req, Db) -> handle_doc_show(Req, DesignName, ShowName, nil, Db); @@ -39,7 +38,7 @@ handle_doc_show_req(#httpd{method='GET'}=Req, _Db) -> send_error(Req, 404, <<"show_error">>, <<"Invalid path.">>); handle_doc_show_req(Req, _Db) -> - send_method_not_allowed(Req, "GET,HEAD"). + send_method_not_allowed(Req, "GET,POST,HEAD"). handle_doc_show(Req, DesignName, ShowName, DocId, Db) -> DesignId = <<"_design/", DesignName/binary>>, @@ -112,7 +111,7 @@ send_view_list_response(Lang, ListSrc, ViewName, DesignId, Req, Db, Keys) -> end. -output_map_list(#httpd{mochi_req=MReq}=Req, Lang, ListSrc, View, Group, Db, QueryArgs, nil) -> +output_map_list(#httpd{mochi_req=MReq, user_ctx=UserCtx}=Req, Lang, ListSrc, View, Group, Db, QueryArgs, nil) -> #view_query_args{ limit = Limit, direction = Dir, @@ -125,7 +124,7 @@ output_map_list(#httpd{mochi_req=MReq}=Req, Lang, ListSrc, View, Group, Db, Quer Headers = MReq:get(headers), Hlist = mochiweb_headers:to_list(Headers), Accept = proplists:get_value('Accept', Hlist), - CurrentEtag = couch_httpd_view:view_group_etag(Group, {Lang, ListSrc, Accept}), + CurrentEtag = couch_httpd_view:view_group_etag(Group, {Lang, ListSrc, Accept, UserCtx}), couch_httpd:etag_respond(Req, CurrentEtag, fun() -> % get the os process here % pass it into the view fold with closures @@ -145,7 +144,7 @@ output_map_list(#httpd{mochi_req=MReq}=Req, Lang, ListSrc, View, Group, Db, Quer finish_list(Req, QueryServer, CurrentEtag, FoldResult, StartListRespFun, RowCount) end); -output_map_list(#httpd{mochi_req=MReq}=Req, Lang, ListSrc, View, Group, Db, QueryArgs, Keys) -> +output_map_list(#httpd{mochi_req=MReq, user_ctx=UserCtx}=Req, Lang, ListSrc, View, Group, Db, QueryArgs, Keys) -> #view_query_args{ limit = Limit, direction = Dir, @@ -156,7 +155,7 @@ output_map_list(#httpd{mochi_req=MReq}=Req, Lang, ListSrc, View, Group, Db, Quer Headers = MReq:get(headers), Hlist = mochiweb_headers:to_list(Headers), Accept = proplists:get_value('Accept', Hlist), - CurrentEtag = couch_httpd_view:view_group_etag(Group, {Lang, ListSrc, Accept}), + CurrentEtag = couch_httpd_view:view_group_etag(Group, {Lang, ListSrc, Accept, UserCtx}), couch_httpd:etag_respond(Req, CurrentEtag, fun() -> % get the os process here % pass it into the view fold with closures @@ -239,7 +238,7 @@ send_non_empty_chunk(Resp, Chunk) -> _ -> send_chunk(Resp, Chunk) end. -output_reduce_list(#httpd{mochi_req=MReq}=Req, Lang, ListSrc, View, Group, Db, QueryArgs, nil) -> +output_reduce_list(#httpd{mochi_req=MReq, user_ctx=UserCtx}=Req, Lang, ListSrc, View, Group, Db, QueryArgs, nil) -> #view_query_args{ limit = Limit, direction = Dir, @@ -256,7 +255,7 @@ output_reduce_list(#httpd{mochi_req=MReq}=Req, Lang, ListSrc, View, Group, Db, Q Headers = MReq:get(headers), Hlist = mochiweb_headers:to_list(Headers), Accept = proplists:get_value('Accept', Hlist), - CurrentEtag = couch_httpd_view:view_group_etag(Group, {Lang, ListSrc, Accept}), + CurrentEtag = couch_httpd_view:view_group_etag(Group, {Lang, ListSrc, Accept, UserCtx}), couch_httpd:etag_respond(Req, CurrentEtag, fun() -> StartListRespFun = make_reduce_start_resp_fun(QueryServer, Req, Db, CurrentEtag), SendListRowFun = make_reduce_send_row_fun(QueryServer, Db), @@ -274,7 +273,7 @@ output_reduce_list(#httpd{mochi_req=MReq}=Req, Lang, ListSrc, View, Group, Db, Q finish_list(Req, QueryServer, CurrentEtag, FoldResult, StartListRespFun, null) end); -output_reduce_list(#httpd{mochi_req=MReq}=Req, Lang, ListSrc, View, Group, Db, QueryArgs, Keys) -> +output_reduce_list(#httpd{mochi_req=MReq, user_ctx=UserCtx}=Req, Lang, ListSrc, View, Group, Db, QueryArgs, Keys) -> #view_query_args{ limit = Limit, direction = Dir, @@ -289,7 +288,7 @@ output_reduce_list(#httpd{mochi_req=MReq}=Req, Lang, ListSrc, View, Group, Db, Q Headers = MReq:get(headers), Hlist = mochiweb_headers:to_list(Headers), Accept = proplists:get_value('Accept', Hlist), - CurrentEtag = couch_httpd_view:view_group_etag(Group, {Lang, ListSrc, Accept, Keys}), + CurrentEtag = couch_httpd_view:view_group_etag(Group, {Lang, ListSrc, Accept, UserCtx, Keys}), couch_httpd:etag_respond(Req, CurrentEtag, fun() -> StartListRespFun = make_reduce_start_resp_fun(QueryServer, Req, Db, CurrentEtag), @@ -332,17 +331,18 @@ finish_list(Req, QueryServer, Etag, FoldResult, StartFun, TotalRows) -> end, send_chunk(Resp, []). + render_head_for_empty_list(StartListRespFun, Req, Etag, null) -> StartListRespFun(Req, Etag, []); % for reduce render_head_for_empty_list(StartListRespFun, Req, Etag, TotalRows) -> StartListRespFun(Req, Etag, TotalRows, null, []). -send_doc_show_response(Lang, ShowSrc, DocId, nil, #httpd{mochi_req=MReq}=Req, Db) -> +send_doc_show_response(Lang, ShowSrc, DocId, nil, #httpd{mochi_req=MReq, user_ctx=UserCtx}=Req, Db) -> % compute etag with no doc Headers = MReq:get(headers), Hlist = mochiweb_headers:to_list(Headers), Accept = proplists:get_value('Accept', Hlist), - CurrentEtag = couch_httpd:make_etag({Lang, ShowSrc, nil, Accept}), + CurrentEtag = couch_httpd:make_etag({Lang, ShowSrc, nil, Accept, UserCtx}), couch_httpd:etag_respond(Req, CurrentEtag, fun() -> [<<"resp">>, ExternalResp] = couch_query_servers:render_doc_show(Lang, ShowSrc, DocId, nil, Req, Db), @@ -350,12 +350,12 @@ send_doc_show_response(Lang, ShowSrc, DocId, nil, #httpd{mochi_req=MReq}=Req, Db couch_httpd_external:send_external_response(Req, JsonResp) end); -send_doc_show_response(Lang, ShowSrc, DocId, #doc{revs=Revs}=Doc, #httpd{mochi_req=MReq}=Req, Db) -> +send_doc_show_response(Lang, ShowSrc, DocId, #doc{revs=Revs}=Doc, #httpd{mochi_req=MReq, user_ctx=UserCtx}=Req, Db) -> % calculate the etag Headers = MReq:get(headers), Hlist = mochiweb_headers:to_list(Headers), Accept = proplists:get_value('Accept', Hlist), - CurrentEtag = couch_httpd:make_etag({Lang, ShowSrc, Revs, Accept}), + CurrentEtag = couch_httpd:make_etag({Lang, ShowSrc, Revs, Accept, UserCtx}), % We know our etag now couch_httpd:etag_respond(Req, CurrentEtag, fun() -> [<<"resp">>, ExternalResp] = couch_query_servers:render_doc_show(Lang, ShowSrc, diff --git a/src/couchdb/couch_httpd_view.erl b/src/couchdb/couch_httpd_view.erl index dc1b853d..2aa7d626 100644 --- a/src/couchdb/couch_httpd_view.erl +++ b/src/couchdb/couch_httpd_view.erl @@ -18,7 +18,7 @@ -export([get_stale_type/1, get_reduce_type/1, parse_view_params/3]). -export([make_view_fold_fun/6, finish_view_fold/3, view_row_obj/3]). -export([view_group_etag/1, view_group_etag/2, make_reduce_fold_funs/5]). --export([design_doc_view/5]). +-export([design_doc_view/5, parse_bool_param/1]). -import(couch_httpd, [send_json/2,send_json/3,send_json/4,send_method_not_allowed/2,send_chunk/2, diff --git a/src/couchdb/couch_rep.erl b/src/couchdb/couch_rep.erl index 71aa0693..b63a2610 100644 --- a/src/couchdb/couch_rep.erl +++ b/src/couchdb/couch_rep.erl @@ -84,7 +84,8 @@ replicate(Source, Target) -> -record(http_db, { uri, - headers + headers, + oauth }). @@ -496,8 +497,8 @@ make_att_stub_receiver(Url, Headers, Name, Type, Length, Retries, Pause) -> end. -open_db({remote, Url, Headers})-> - {ok, #http_db{uri=?b2l(Url), headers=Headers}, Url}; +open_db({remote, Url, Headers, Auth})-> + {ok, #http_db{uri=?b2l(Url), headers=Headers, oauth=Auth}, Url}; open_db({local, DbName, UserCtx})-> case couch_db:open(DbName, [{user_ctx, UserCtx}]) of {ok, Db} -> {ok, Db, DbName}; @@ -600,19 +601,38 @@ do_checkpoint(Source, Target, Context, NewSeqNum, Stats) -> end. -do_http_request(Url, Action, Headers) -> - do_http_request(Url, Action, Headers, []). - -do_http_request(Url, Action, Headers, JsonBody) -> - do_http_request(Url, Action, Headers, JsonBody, 10, 1000). +do_http_request(Url, Action, Headers, Auth) -> + do_http_request(Url, Action, Headers, Auth, []). + +do_http_request(Url, Action, Headers, Auth, JsonBody) -> + Headers0 = case Auth of + {Props} -> + % Add OAuth header + {OAuth} = proplists:get_value(<<"oauth">>, Props), + ConsumerKey = ?b2l(proplists:get_value(<<"consumer_key">>, OAuth)), + Token = ?b2l(proplists:get_value(<<"token">>, OAuth)), + TokenSecret = ?b2l(proplists:get_value(<<"token_secret">>, OAuth)), + ConsumerSecret = ?b2l(proplists:get_value(<<"consumer_secret">>, OAuth)), + Consumer = {ConsumerKey, ConsumerSecret, hmac_sha1}, + Method = case Action of + get -> "GET"; + post -> "POST"; + put -> "PUT" + end, + Params = oauth:signed_params(Method, Url, [], Consumer, Token, TokenSecret), + [{"Authorization", "OAuth " ++ oauth_uri:params_to_header_string(Params)} | Headers]; + _Else -> + Headers + end, + do_http_request0(Url, Action, Headers0, JsonBody, 10, 1000). -do_http_request(Url, Action, Headers, Body, Retries, Pause) when is_binary(Url) -> - do_http_request(?b2l(Url), Action, Headers, Body, Retries, Pause); -do_http_request(Url, Action, _Headers, _JsonBody, 0, _Pause) -> +do_http_request0(Url, Action, Headers, Body, Retries, Pause) when is_binary(Url) -> + do_http_request0(?b2l(Url), Action, Headers, Body, Retries, Pause); +do_http_request0(Url, Action, _Headers, _JsonBody, 0, _Pause) -> ?LOG_ERROR("couch_rep HTTP ~p request failed after 10 retries: ~s", [Action, Url]), exit({http_request_failed, ?l2b(["failed to replicate ", Url])}); -do_http_request(Url, Action, Headers, JsonBody, Retries, Pause) -> +do_http_request0(Url, Action, Headers, JsonBody, Retries, Pause) -> ?LOG_DEBUG("couch_rep HTTP ~p request: ~s", [Action, Url]), Body = case JsonBody of @@ -638,7 +658,7 @@ do_http_request(Url, Action, Headers, JsonBody, Retries, Pause) -> ResponseCode >= 300, ResponseCode < 400 -> RedirectUrl = mochiweb_headers:get_value("Location", mochiweb_headers:make(ResponseHeaders)), - do_http_request(RedirectUrl, Action, Headers, JsonBody, Retries-1, + do_http_request0(RedirectUrl, Action, Headers, JsonBody, Retries-1, Pause); ResponseCode >= 400, ResponseCode < 500 -> ?JSON_DECODE(ResponseBody); @@ -646,18 +666,18 @@ do_http_request(Url, Action, Headers, JsonBody, Retries, Pause) -> ?LOG_INFO("retrying couch_rep HTTP ~p request in ~p seconds " ++ "due to 500 error: ~s", [Action, Pause/1000, Url]), timer:sleep(Pause), - do_http_request(Url, Action, Headers, JsonBody, Retries - 1, 2*Pause) + do_http_request0(Url, Action, Headers, JsonBody, Retries - 1, 2*Pause) end; {error, Reason} -> ?LOG_INFO("retrying couch_rep HTTP ~p request in ~p seconds due to " ++ "{error, ~p}: ~s", [Action, Pause/1000, Reason, Url]), timer:sleep(Pause), - do_http_request(Url, Action, Headers, JsonBody, Retries - 1, 2*Pause) + do_http_request0(Url, Action, Headers, JsonBody, Retries - 1, 2*Pause) end. -ensure_full_commit(#http_db{uri=DbUrl, headers=Headers}) -> +ensure_full_commit(#http_db{uri=DbUrl, headers=Headers, oauth=OAuth}) -> {ResultProps} = do_http_request(DbUrl ++ "_ensure_full_commit", post, - Headers, true), + Headers, OAuth, true), true = proplists:get_value(<<"ok">>, ResultProps), {ok, proplists:get_value(<<"instance_start_time">>, ResultProps)}; ensure_full_commit(Db) -> @@ -687,16 +707,16 @@ enum_docs_since(Pid, DbSource, DbTarget, {StartSeq, RevsCount}) -> -get_db_info(#http_db{uri=DbUrl, headers=Headers}) -> - {DbProps} = do_http_request(DbUrl, get, Headers), +get_db_info(#http_db{uri=DbUrl, headers=Headers, oauth=OAuth}) -> + {DbProps} = do_http_request(DbUrl, get, Headers, OAuth), {ok, [{list_to_atom(?b2l(K)), V} || {K,V} <- DbProps]}; get_db_info(Db) -> couch_db:get_db_info(Db). -get_doc_info_list(#http_db{uri=DbUrl, headers=Headers}, StartSeq) -> +get_doc_info_list(#http_db{uri=DbUrl, headers=Headers, oauth=OAuth}, StartSeq) -> Url = DbUrl ++ "_all_docs_by_seq?limit=100&startkey=" ++ integer_to_list(StartSeq), - {Results} = do_http_request(Url, get, Headers), + {Results} = do_http_request(Url, get, Headers, OAuth), lists:map(fun({RowInfoList}) -> {RowValueProps} = proplists:get_value(<<"value">>, RowInfoList), Seq = proplists:get_value(<<"key">>, RowInfoList), @@ -719,9 +739,9 @@ get_doc_info_list(DbSource, StartSeq) -> end, {0, []}), lists:reverse(DocInfoList). -get_missing_revs(#http_db{uri=DbUrl, headers=Headers}, DocIdRevsList) -> +get_missing_revs(#http_db{uri=DbUrl, headers=Headers, oauth=OAuth}, DocIdRevsList) -> DocIdRevsList2 = [{Id, couch_doc:rev_to_strs(Revs)} || {Id, Revs} <- DocIdRevsList], - {ResponseMembers} = do_http_request(DbUrl ++ "_missing_revs", post, Headers, + {ResponseMembers} = do_http_request(DbUrl ++ "_missing_revs", post, Headers, OAuth, {DocIdRevsList2}), {DocMissingRevsList} = proplists:get_value(<<"missing_revs">>, ResponseMembers), DocMissingRevsList2 = [{Id, couch_doc:parse_revs(MissingRevStrs)} || {Id, MissingRevStrs} <- DocMissingRevsList], @@ -730,9 +750,9 @@ get_missing_revs(Db, DocId) -> couch_db:get_missing_revs(Db, DocId). -open_doc(#http_db{uri=DbUrl, headers=Headers}, DocId, Options) -> +open_doc(#http_db{uri=DbUrl, headers=Headers, oauth=OAuth}, DocId, Options) -> [] = Options, - case do_http_request(DbUrl ++ couch_util:url_encode(DocId), get, Headers) of + case do_http_request(DbUrl ++ couch_util:url_encode(DocId), get, Headers, OAuth) of {[{<<"error">>, ErrId}, {<<"reason">>, Reason}]} -> {couch_util:to_existing_atom(ErrId), Reason}; Doc -> @@ -741,7 +761,7 @@ open_doc(#http_db{uri=DbUrl, headers=Headers}, DocId, Options) -> open_doc(Db, DocId, Options) -> couch_db:open_doc(Db, DocId, Options). -open_doc_revs(#http_db{uri=DbUrl, headers=Headers} = DbS, DocId, Revs0, +open_doc_revs(#http_db{uri=DbUrl, headers=Headers, oauth=OAuth} = DbS, DocId, Revs0, [latest]) -> Revs = couch_doc:rev_to_strs(Revs0), BaseUrl = DbUrl ++ couch_util:url_encode(DocId) ++ "?revs=true&latest=true", @@ -752,18 +772,18 @@ open_doc_revs(#http_db{uri=DbUrl, headers=Headers} = DbS, DocId, Revs0, JsonResults = case length(Revs) > MaxN of false -> Url = ?l2b(BaseUrl ++ "&open_revs=" ++ ?JSON_ENCODE(Revs)), - do_http_request(Url, get, Headers); + do_http_request(Url, get, Headers, OAuth); true -> {_, Rest, Acc} = lists:foldl( fun(Rev, {Count, RevsAcc, AccResults}) when Count =:= MaxN -> QSRevs = ?JSON_ENCODE(lists:reverse(RevsAcc)), Url = ?l2b(BaseUrl ++ "&open_revs=" ++ QSRevs), - {1, [Rev], AccResults++do_http_request(Url, get, Headers)}; + {1, [Rev], AccResults++do_http_request(Url, get, Headers, OAuth)}; (Rev, {Count, RevsAcc, AccResults}) -> {Count+1, [Rev|RevsAcc], AccResults} end, {0, [], []}, Revs), Acc ++ do_http_request(?l2b(BaseUrl ++ "&open_revs=" ++ - ?JSON_ENCODE(lists:reverse(Rest))), get, Headers) + ?JSON_ENCODE(lists:reverse(Rest))), get, Headers, OAuth) end, Results = @@ -825,10 +845,10 @@ binary_memory(Pid) -> lists:foldl(fun({_Id, Size, _NRefs}, Acc) -> Size+Acc end, 0, element(2,process_info(Pid, binary))). -update_doc(#http_db{uri=DbUrl, headers=Headers}, #doc{id=DocId}=Doc, Options) -> +update_doc(#http_db{uri=DbUrl, headers=Headers, oauth=OAuth}, #doc{id=DocId}=Doc, Options) -> [] = Options, Url = DbUrl ++ couch_util:url_encode(DocId), - {ResponseMembers} = do_http_request(Url, put, Headers, + {ResponseMembers} = do_http_request(Url, put, Headers, OAuth, couch_doc:to_json_obj(Doc, [attachments])), Rev = proplists:get_value(<<"rev">>, ResponseMembers), {ok, couch_doc:parse_rev(Rev)}; @@ -837,10 +857,10 @@ update_doc(Db, Doc, Options) -> update_docs(_, [], _, _) -> {ok, []}; -update_docs(#http_db{uri=DbUrl, headers=Headers}, Docs, [], replicated_changes) -> +update_docs(#http_db{uri=DbUrl, headers=Headers, oauth=OAuth}, Docs, [], replicated_changes) -> JsonDocs = [couch_doc:to_json_obj(Doc, [revs,attachments]) || Doc <- Docs], ErrorsJson = - do_http_request(DbUrl ++ "_bulk_docs", post, Headers, + do_http_request(DbUrl ++ "_bulk_docs", post, Headers, OAuth, {[{new_edits, false}, {docs, JsonDocs}]}), ErrorsList = lists:map( diff --git a/src/couchdb/couch_util.erl b/src/couchdb/couch_util.erl index 71c6aea9..e31bd0e7 100644 --- a/src/couchdb/couch_util.erl +++ b/src/couchdb/couch_util.erl @@ -16,7 +16,8 @@ -export([should_flush/0, should_flush/1, to_existing_atom/1]). -export([new_uuid/0, rand32/0, implode/2, collate/2, collate/3]). -export([abs_pathname/1,abs_pathname/2, trim/1, ascii_lower/1]). --export([encodeBase64/1, decodeBase64/1, to_hex/1,parse_term/1,dict_find/3]). +-export([encodeBase64/1, decodeBase64/1, encodeBase64Url/1, decodeBase64Url/1, + to_hex/1,parse_term/1, dict_find/3]). -export([file_read_size/1, get_nested_json_value/2, json_user_ctx/1]). -export([to_binary/1, to_list/1, url_encode/1]). @@ -259,6 +260,23 @@ encodeBase64(<<B:1/binary>>, Acc) -> encodeBase64(<<>>, Acc) -> Acc. +encodeBase64Url(Bs) when list(Bs) -> + encodeBase64Url(list_to_binary(Bs), <<>>); +encodeBase64Url(Bs) -> + encodeBase64Url(Bs, <<>>). + +encodeBase64Url(<<B:3/binary, Bs/binary>>, Acc) -> + <<C1:6, C2:6, C3:6, C4:6>> = B, + encodeBase64Url(Bs, <<Acc/binary, (encUrl(C1)), (encUrl(C2)), (encUrl(C3)), (encUrl(C4))>>); +encodeBase64Url(<<B:2/binary>>, Acc) -> + <<C1:6, C2:6, C3:6, _:6>> = <<B/binary, 0>>, + <<Acc/binary, (encUrl(C1)), (encUrl(C2)), (encUrl(C3))>>; +encodeBase64Url(<<B:1/binary>>, Acc) -> + <<C1:6, C2:6, _:12>> = <<B/binary, 0, 0>>, + <<Acc/binary, (encUrl(C1)), (encUrl(C2))>>; +encodeBase64Url(<<>>, Acc) -> + Acc. + %% %% decodeBase64(BinaryChars) -> Binary %% @@ -279,6 +297,23 @@ decode1(<<C1, C2, C3, C4, Cs/binary>>, Acc) -> decode1(<<>>, Acc) -> Acc. +decodeBase64Url(Cs) when is_list(Cs)-> + decodeBase64Url(list_to_binary(Cs)); +decodeBase64Url(Cs) -> + decode1Url(Cs, <<>>). + +decode1Url(<<C1, C2>>, Acc) -> + <<B1, _:16>> = <<(decUrl(C1)):6, (decUrl(C2)):6, 0:12>>, + <<Acc/binary, B1>>; +decode1Url(<<C1, C2, C3>>, Acc) -> + <<B1, B2, _:8>> = <<(decUrl(C1)):6, (decUrl(C2)):6, (decUrl(C3)):6, (decUrl(0)):6>>, + <<Acc/binary, B1, B2>>; +decode1Url(<<C1, C2, C3, C4, Cs/binary>>, Acc) -> + Bin = <<Acc/binary, (decUrl(C1)):6, (decUrl(C2)):6, (decUrl(C3)):6, (decUrl(C4)):6>>, + decode1Url(Cs, Bin); +decode1Url(<<>>, Acc) -> + Acc. + %% enc/1 and dec/1 %% %% Mapping: 0-25 -> A-Z, 26-51 -> a-z, 52-61 -> 0-9, 62 -> +, 63 -> / @@ -289,6 +324,15 @@ enc(C) -> dec(C) -> 62*?st(C,43) + ?st(C,47) + (C-59)*?st(C,48) - 69*?st(C,65) - 6*?st(C,97). +%% encUrl/1 and decUrl/1 +%% +%% Mapping: 0-25 -> A-Z, 26-51 -> a-z, 52-61 -> 0-9, 62 -> -, 63 -> _ +%% +encUrl(C) -> + 65 + C + 6*?st(C,26) - 75*?st(C,52) -13*?st(C,62) + 49*?st(C,63). + +decUrl(C) -> + 62*?st(C,45) + (C-58)*?st(C,48) - 69*?st(C,65) + 33*?st(C,95) - 39*?st(C,97). dict_find(Key, Dict, DefaultValue) -> case dict:find(Key, Dict) of diff --git a/src/erlang-oauth/Makefile.am b/src/erlang-oauth/Makefile.am new file mode 100644 index 00000000..e20bf2b1 --- /dev/null +++ b/src/erlang-oauth/Makefile.am @@ -0,0 +1,47 @@ +## Licensed under the Apache License, Version 2.0 (the "License"); you may not +## use this file except in compliance with the License. You may obtain a copy +## of the License at +## +## http://www.apache.org/licenses/LICENSE-2.0 +## +## Unless required by applicable law or agreed to in writing, software +## distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +## WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +## License for the specific language governing permissions and limitations under +## the License. + +oauthebindir = $(localerlanglibdir)/erlang-oauth/ebin + +oauth_file_collection = \ + oauth.erl \ + oauth_hmac_sha1.erl \ + oauth_http.erl \ + oauth_plaintext.erl \ + oauth_rsa_sha1.erl \ + oauth_unix.erl \ + oauth_uri.erl + +oauthebin_static_file = oauth.app + +oauthebin_make_generated_file_list = \ + oauth.beam \ + oauth_hmac_sha1.beam \ + oauth_http.beam \ + oauth_plaintext.beam \ + oauth_rsa_sha1.beam \ + oauth_unix.beam \ + oauth_uri.beam + +oauthebin_DATA = \ + $(oauthebin_static_file) \ + $(oauthebin_make_generated_file_list) + +EXTRA_DIST = \ + $(oauth_file_collection) \ + $(oauthebin_static_file) + +CLEANFILES = \ + $(oauthebin_make_generated_file_list) + +%.beam: %.erl + $(ERLC) $(ERLC_FLAGS) $< diff --git a/src/erlang-oauth/oauth.app b/src/erlang-oauth/oauth.app new file mode 100644 index 00000000..6357b9b0 --- /dev/null +++ b/src/erlang-oauth/oauth.app @@ -0,0 +1,20 @@ +{application, oauth, [ + {description, "Erlang OAuth implementation"}, + {vsn, "dev"}, + {modules, [ + oauth, + oauth_hmac_sha1, + oauth_http, + oauth_plaintext, + oauth_rsa_sha1, + oauth_unix, + oauth_uri + ]}, + {registered, []}, + {applications, [ + kernel, + stdlib, + crypto, + inets + ]} +]}. diff --git a/src/erlang-oauth/oauth.erl b/src/erlang-oauth/oauth.erl new file mode 100644 index 00000000..866655c9 --- /dev/null +++ b/src/erlang-oauth/oauth.erl @@ -0,0 +1,107 @@ +-module(oauth). + +-export( + [ get/5 + , header/1 + , post/5 + , signature/5 + , signature_base_string/3 + , signed_params/6 + , token/1 + , token_secret/1 + , uri/2 + , verify/6 + ]). + + +get(URL, ExtraParams, Consumer, Token, TokenSecret) -> + SignedParams = signed_params("GET", URL, ExtraParams, Consumer, Token, TokenSecret), + oauth_http:get(uri(URL, SignedParams)). + +post(URL, ExtraParams, Consumer, Token, TokenSecret) -> + SignedParams = signed_params("POST", URL, ExtraParams, Consumer, Token, TokenSecret), + oauth_http:post(URL, oauth_uri:params_to_string(SignedParams)). + +uri(Base, []) -> + Base; +uri(Base, Params) -> + lists:concat([Base, "?", oauth_uri:params_to_string(Params)]). + +header(Params) -> + {"Authorization", "OAuth " ++ oauth_uri:params_to_header_string(Params)}. + +token(Params) -> + proplists:get_value("oauth_token", Params). + +token_secret(Params) -> + proplists:get_value("oauth_token_secret", Params). + +verify(Signature, HttpMethod, URL, Params, Consumer, TokenSecret) -> + case signature_method(Consumer) of + plaintext -> + oauth_plaintext:verify(Signature, consumer_secret(Consumer), TokenSecret); + hmac_sha1 -> + BaseString = signature_base_string(HttpMethod, URL, Params), + oauth_hmac_sha1:verify(Signature, BaseString, consumer_secret(Consumer), TokenSecret); + rsa_sha1 -> + BaseString = signature_base_string(HttpMethod, URL, Params), + oauth_rsa_sha1:verify(Signature, BaseString, consumer_secret(Consumer)) + end. + +signed_params(HttpMethod, URL, ExtraParams, Consumer, Token, TokenSecret) -> + Params = token_param(Token, params(Consumer, ExtraParams)), + [{"oauth_signature", signature(HttpMethod, URL, Params, Consumer, TokenSecret)}|Params]. + +signature(HttpMethod, URL, Params, Consumer, TokenSecret) -> + case signature_method(Consumer) of + plaintext -> + oauth_plaintext:signature(consumer_secret(Consumer), TokenSecret); + hmac_sha1 -> + BaseString = signature_base_string(HttpMethod, URL, Params), + oauth_hmac_sha1:signature(BaseString, consumer_secret(Consumer), TokenSecret); + rsa_sha1 -> + BaseString = signature_base_string(HttpMethod, URL, Params), + oauth_rsa_sha1:signature(BaseString, consumer_secret(Consumer)) + end. + +signature_base_string(HttpMethod, URL, Params) -> + NormalizedURL = oauth_uri:normalize(URL), + NormalizedParams = oauth_uri:params_to_string(lists:sort(Params)), + oauth_uri:calate("&", [HttpMethod, NormalizedURL, NormalizedParams]). + +token_param("", Params) -> + Params; +token_param(Token, Params) -> + [{"oauth_token", Token}|Params]. + +params(Consumer, Params) -> + Nonce = base64:encode_to_string(crypto:rand_bytes(32)), % cf. ruby-oauth + params(Consumer, oauth_unix:timestamp(), Nonce, Params). + +params(Consumer, Timestamp, Nonce, Params) -> + [ {"oauth_version", "1.0"} + , {"oauth_nonce", Nonce} + , {"oauth_timestamp", integer_to_list(Timestamp)} + , {"oauth_signature_method", signature_method_string(Consumer)} + , {"oauth_consumer_key", consumer_key(Consumer)} + | Params + ]. + +signature_method_string(Consumer) -> + case signature_method(Consumer) of + plaintext -> + "PLAINTEXT"; + hmac_sha1 -> + "HMAC-SHA1"; + rsa_sha1 -> + "RSA-SHA1" + end. + +signature_method(_Consumer={_, _, Method}) -> + Method. + +consumer_secret(_Consumer={_, Secret, _}) -> + Secret. + +consumer_key(_Consumer={Key, _, _}) -> + Key. diff --git a/src/erlang-oauth/oauth_hmac_sha1.erl b/src/erlang-oauth/oauth_hmac_sha1.erl new file mode 100644 index 00000000..69064edd --- /dev/null +++ b/src/erlang-oauth/oauth_hmac_sha1.erl @@ -0,0 +1,11 @@ +-module(oauth_hmac_sha1). + +-export([signature/3, verify/4]). + + +signature(BaseString, CS, TS) -> + Key = oauth_uri:calate("&", [CS, TS]), + base64:encode_to_string(crypto:sha_mac(Key, BaseString)). + +verify(Signature, BaseString, CS, TS) -> + Signature =:= signature(BaseString, CS, TS). diff --git a/src/erlang-oauth/oauth_http.erl b/src/erlang-oauth/oauth_http.erl new file mode 100644 index 00000000..bf5a4bac --- /dev/null +++ b/src/erlang-oauth/oauth_http.erl @@ -0,0 +1,22 @@ +-module(oauth_http). + +-export([get/1, post/2, response_params/1, response_body/1, response_code/1]). + + +get(URL) -> + request(get, {URL, []}). + +post(URL, Data) -> + request(post, {URL, [], "application/x-www-form-urlencoded", Data}). + +request(Method, Request) -> + http:request(Method, Request, [{autoredirect, false}], []). + +response_params(Response) -> + oauth_uri:params_from_string(response_body(Response)). + +response_body({{_, _, _}, _, Body}) -> + Body. + +response_code({{_, Code, _}, _, _}) -> + Code. diff --git a/src/erlang-oauth/oauth_plaintext.erl b/src/erlang-oauth/oauth_plaintext.erl new file mode 100644 index 00000000..d8085e02 --- /dev/null +++ b/src/erlang-oauth/oauth_plaintext.erl @@ -0,0 +1,10 @@ +-module(oauth_plaintext). + +-export([signature/2, verify/3]). + + +signature(CS, TS) -> + oauth_uri:calate("&", [CS, TS]). + +verify(Signature, CS, TS) -> + Signature =:= signature(CS, TS). diff --git a/src/erlang-oauth/oauth_rsa_sha1.erl b/src/erlang-oauth/oauth_rsa_sha1.erl new file mode 100644 index 00000000..6f4828e0 --- /dev/null +++ b/src/erlang-oauth/oauth_rsa_sha1.erl @@ -0,0 +1,30 @@ +-module(oauth_rsa_sha1). + +-export([signature/2, verify/3]). + +-include_lib("public_key/include/public_key.hrl"). + + +signature(BaseString, PrivateKeyPath) -> + {ok, [Info]} = public_key:pem_to_der(PrivateKeyPath), + {ok, PrivateKey} = public_key:decode_private_key(Info), + base64:encode_to_string(public_key:sign(list_to_binary(BaseString), PrivateKey)). + +verify(Signature, BaseString, PublicKey) -> + public_key:verify_signature(to_binary(BaseString), sha, base64:decode(Signature), public_key(PublicKey)). + +to_binary(Term) when is_list(Term) -> + list_to_binary(Term); +to_binary(Term) when is_binary(Term) -> + Term. + +public_key(Path) when is_list(Path) -> + {ok, [{cert, DerCert, not_encrypted}]} = public_key:pem_to_der(Path), + {ok, Cert} = public_key:pkix_decode_cert(DerCert, otp), + public_key(Cert); +public_key(#'OTPCertificate'{tbsCertificate=Cert}) -> + public_key(Cert); +public_key(#'OTPTBSCertificate'{subjectPublicKeyInfo=Info}) -> + public_key(Info); +public_key(#'OTPSubjectPublicKeyInfo'{subjectPublicKey=Key}) -> + Key. diff --git a/src/erlang-oauth/oauth_unix.erl b/src/erlang-oauth/oauth_unix.erl new file mode 100644 index 00000000..73ca3143 --- /dev/null +++ b/src/erlang-oauth/oauth_unix.erl @@ -0,0 +1,16 @@ +-module(oauth_unix). + +-export([timestamp/0]). + + +timestamp() -> + timestamp(calendar:universal_time()). + +timestamp(DateTime) -> + seconds(DateTime) - epoch(). + +epoch() -> + seconds({{1970,1,1},{00,00,00}}). + +seconds(DateTime) -> + calendar:datetime_to_gregorian_seconds(DateTime). diff --git a/src/erlang-oauth/oauth_uri.erl b/src/erlang-oauth/oauth_uri.erl new file mode 100644 index 00000000..fb27ae72 --- /dev/null +++ b/src/erlang-oauth/oauth_uri.erl @@ -0,0 +1,88 @@ +-module(oauth_uri). + +-export([normalize/1, calate/2, encode/1]). +-export([params_from_string/1, params_to_string/1, + params_from_header_string/1, params_to_header_string/1]). + +-import(lists, [concat/1]). + +-define(is_uppercase_alpha(C), C >= $A, C =< $Z). +-define(is_lowercase_alpha(C), C >= $a, C =< $z). +-define(is_alpha(C), ?is_uppercase_alpha(C); ?is_lowercase_alpha(C)). +-define(is_digit(C), C >= $0, C =< $9). +-define(is_alphanumeric(C), ?is_alpha(C); ?is_digit(C)). +-define(is_unreserved(C), ?is_alphanumeric(C); C =:= $-; C =:= $_; C =:= $.; C =:= $~). +-define(is_hex(C), ?is_digit(C); C >= $A, C =< $F). + + +normalize(URI) -> + case http_uri:parse(URI) of + {Scheme, UserInfo, Host, Port, Path, _Query} -> + normalize(Scheme, UserInfo, string:to_lower(Host), Port, [Path]); + Else -> + Else + end. + +normalize(http, UserInfo, Host, 80, Acc) -> + normalize(http, UserInfo, [Host|Acc]); +normalize(https, UserInfo, Host, 443, Acc) -> + normalize(https, UserInfo, [Host|Acc]); +normalize(Scheme, UserInfo, Host, Port, Acc) -> + normalize(Scheme, UserInfo, [Host, ":", Port|Acc]). + +normalize(Scheme, [], Acc) -> + concat([Scheme, "://"|Acc]); +normalize(Scheme, UserInfo, Acc) -> + concat([Scheme, "://", UserInfo, "@"|Acc]). + +params_to_header_string(Params) -> + intercalate(", ", [concat([encode(K), "=\"", encode(V), "\""]) || {K, V} <- Params]). + +params_from_header_string(String) -> + [param_from_header_string(Param) || Param <- re:split(String, ",\\s*", [{return, list}]), Param =/= ""]. + +param_from_header_string(Param) -> + [Key, QuotedValue] = string:tokens(Param, "="), + Value = string:substr(QuotedValue, 2, length(QuotedValue) - 2), + {decode(Key), decode(Value)}. + +params_from_string(Params) -> + [param_from_string(Param) || Param <- string:tokens(Params, "&")]. + +param_from_string(Param) -> + list_to_tuple([decode(Value) || Value <- string:tokens(Param, "=")]). + +params_to_string(Params) -> + intercalate("&", [calate("=", [K, V]) || {K, V} <- Params]). + +calate(Sep, Xs) -> + intercalate(Sep, [encode(X) || X <- Xs]). + +intercalate(Sep, Xs) -> + concat(intersperse(Sep, Xs)). + +intersperse(_, []) -> []; +intersperse(_, [X]) -> [X]; +intersperse(Sep, [X|Xs]) -> + [X, Sep|intersperse(Sep, Xs)]. + +decode(Chars) -> + decode(Chars, []). + +decode([], Decoded) -> + lists:reverse(Decoded); +decode([$%,A,B|Etc], Decoded) when ?is_hex(A), ?is_hex(B) -> + decode(Etc, [erlang:list_to_integer([A,B], 16)|Decoded]); +decode([C|Etc], Decoded) when ?is_unreserved(C) -> + decode(Etc, [C|Decoded]). + +encode(Chars) -> + encode(Chars, []). + +encode([], Encoded) -> + lists:flatten(lists:reverse(Encoded)); +encode([C|Etc], Encoded) when ?is_unreserved(C) -> + encode(Etc, [C|Encoded]); +encode([C|Etc], Encoded) -> + Value = io_lib:format("%~2.1.0s", [erlang:integer_to_list(C, 16)]), + encode(Etc, [Value|Encoded]). diff --git a/src/mochiweb/mochiweb_cookies.erl b/src/mochiweb/mochiweb_cookies.erl index b9da37b4..61711ff0 100644 --- a/src/mochiweb/mochiweb_cookies.erl +++ b/src/mochiweb/mochiweb_cookies.erl @@ -32,7 +32,7 @@ cookie(Key, Value) -> %% @spec cookie(Key::string(), Value::string(), Options::[Option]) -> header() %% where Option = {max_age, integer()} | {local_time, {date(), time()}} %% | {domain, string()} | {path, string()} -%% | {secure, true | false} +%% | {secure, true | false} | {http_only, true | false} %% %% @doc Generate a Set-Cookie header field tuple. cookie(Key, Value, Options) -> @@ -83,7 +83,14 @@ cookie(Key, Value, Options) -> Path -> ["; Path=", quote(Path)] end, - CookieParts = [Cookie, ExpiresPart, SecurePart, DomainPart, PathPart], + HttpOnlyPart = + case proplists:get_value(http_only, Options) of + true -> + "; HttpOnly"; + _ -> + "" + end, + CookieParts = [Cookie, ExpiresPart, SecurePart, DomainPart, PathPart, HttpOnlyPart], {"Set-Cookie", lists:flatten(CookieParts)}. diff --git a/test/runner.sh b/test/runner.sh index 7890a259..e1936b7e 100755 --- a/test/runner.sh +++ b/test/runner.sh @@ -19,5 +19,5 @@ erl -noshell -pa ../src/couchdb -pa ../src/mochiweb -eval "runner:run()" cat ../share/www/script/couch.js \ ../share/www/script/couch_test_runner.js \ ../share/www/script/couch_tests.js \ - ../share/www/script/test/* test.js \ + ../share/www/script/test/*.js test.js \ | ../src/couchdb/couchjs - diff --git a/utils/Makefile.am b/utils/Makefile.am index c4bc0ff2..6ac49511 100644 --- a/utils/Makefile.am +++ b/utils/Makefile.am @@ -25,6 +25,7 @@ run: ../bin/couchdb.tpl -e "s|%mochiwebebindir%|mochiweb|g" \ -e "s|%couchdbebindir%|couchdb|g" \ -e "s|%ibrowseebindir%|ibrowse|g" \ + -e "s|%oauthebindir%|erlang-oauth|g" \ -e "s|%defaultini%|default_dev.ini|g" \ -e "s|%localini%|local_dev.ini|g" \ -e "s|%localerlanglibdir%|$(abs_top_srcdir)/src|g" \ |