[Carried over from user@; thoughts on requiring users to validate their
E-mail address as a username]
On Thu, Jan 14, 2010 at 09:38:34AM +0000, Brian Candler wrote:
> I think what's needed is something *like* a cookie, which can be generated
> by an external process and validated by couchdb. Perhaps even the existing
> cookie auth mechanism could be overloaded/abused. Put simply:
> validate_doc_update won't let you create user "[email protected]" unless it
> sees that you are already logged in as "[email protected]". That's trivial to
> implement.
I've had a go at implementing this, and the attached patch includes tests
for this way of working. (*)
The good news:
* because validate_doc_update is stackable, it's very easy to add your own
policies for valid usernames, just by adding another design doc with its own
validate_doc_update.
* if you send someone an auth cookie via E-mail, not only does that validate
their E-mail address, but it solves the "forgotten password" problem too
(just ask the system to E-mail you another cookie, login, and change your
password).
There are a couple of issues though.
(1) I found that the cookie_authentication_handler would not let you login,
even if your cookie was valid, if you did not already have an entry in the
users database. It turns out this is because it looks up user's salt and
includes it in the cookie hash.
I can't see a justification for that, so in the attached patch I have
removed it. Please correct me if I've overlooked something.
(I think what *would* improve security would be using a HMAC_SHA1 instead of
a plain SHA1 with the server secret: see RFC 2104, RFC 2202)
(2) The cookie currently holds the time it was created, not the time it
expires. It would be very useful if you could E-mail out a cookie with an
expiry time further in the future (say 72 hours), whilst still leaving http
cookies at 10 minutes.
Making the cookie carry the expiry time would be straightforward to change.
Are you happy for me to do so?
Regards,
Brian.
(*) You still need an _external handler to calculate the cookie and E-mail
it out, and to convert the activation link into a Set-Cookie
diff --git a/share/www/script/test/cookie_auth.js b/share/www/script/test/cookie_auth.js
index 125a6dc..291b442 100644
--- a/share/www/script/test/cookie_auth.js
+++ b/share/www/script/test/cookie_auth.js
@@ -224,6 +224,88 @@ couchTests.cookie_auth = function(debug) {
T(CouchDB.session().roles.indexOf("foo") != -1);
});
+ // verify_doc_update is stackable, so users can apply site policies for
+ // registration and validation of accounts. The idea here is that
+ // you use an _external process to mail out a link containing a cookie
+ // signed in the same way as the cookie_auth module, which in turn
+ // permits the user to create an account with that name (and to reset
+ // forgotten passwords too)
+
+ var emailUserDoc = CouchDB.prepareUserDoc({
+ username: "[email protected]",
+ roles: []
+ }, password);
+ T(usersDb.save(emailUserDoc).ok);
+ T(CouchDB.logout().ok);
+
+ var policyDoc = {
+ _id:"_design/signup_policy",
+ language: "javascript",
+ validate_doc_update: stringFun(function (newDoc, oldDoc, userCtx) {
+ if (!oldDoc && newDoc) { // && userCtx.roles.indexOf('_admin') == -1
+ if (!newDoc.username || !newDoc.username.match(/@/)) {
+ throw {forbidden:
+ "Username must be an E-mail address"};
+ }
+ if (newDoc.username != userCtx.name) {
+ throw {forbidden:
+ "E-mail address not validated"};
+ }
+ }
+ }),
+ shows: {
+ setcookie: stringFun(function(doc, req) {
+ return {
+ code: 302,
+ headers: {
+ Location: "/_utils", // Does CouchDB prefix host and db?
+ "Set-Cookie": req.query.cookie
+ }
+ }
+ })
+ }
+ };
+ T(usersDb.save(policyDoc).ok);
+
+ // Format of username
+ var badUserDoc = CouchDB.prepareUserDoc({
+ username: "example.com",
+ roles: []
+ }, password);
+ try {
+ usersDb.save(badUserDoc)
+ T(false && "policy doc forbids username without @");
+ } catch (e) {
+ T(e.error == "forbidden");
+ T(e.reason == "Username must be an E-mail address");
+ }
+
+ // While logged in, we can delete and create users doc
+ T(CouchDB.login("[email protected]", password).ok);
+ T(usersDb.deleteDoc(emailUserDoc).ok);
+ emailUserDoc = CouchDB.prepareUserDoc({
+ username: "[email protected]",
+ roles: []
+ }, password);
+ T(usersDb.save(emailUserDoc).ok);
+ T(usersDb.deleteDoc(emailUserDoc).ok);
+
+ // When not logged in, we can't
+ T(CouchDB.logout().ok);
+ emailUserDoc = CouchDB.prepareUserDoc({
+ username: "[email protected]",
+ roles: []
+ }, password);
+ try {
+ usersDb.save(emailUserDoc)
+ T(false && "policy doc requires pre-existing cookie");
+ } catch (e) {
+ T(e.error == "forbidden");
+ T(e.reason == "E-mail address not validated");
+ }
+
+ T(usersDb.deleteDoc(policyDoc).ok);
+
} finally {
// Make sure we erase any auth cookies so we don't affect other tests
T(CouchDB.logout().ok);
diff --git a/src/couchdb/couch_httpd_auth.erl b/src/couchdb/couch_httpd_auth.erl
index d7b8181..3c6dbf0 100644
--- a/src/couchdb/couch_httpd_auth.erl
+++ b/src/couchdb/couch_httpd_auth.erl
@@ -259,32 +259,30 @@ cookie_authentication_handler(#httpd{mochi_req=MochiReq}=Req) ->
Req;
SecretStr ->
Secret = ?l2b(SecretStr),
- case get_user(?l2b(User)) of
- nil -> Req;
- UserProps ->
- UserSalt = proplists:get_value(<<"salt">>, UserProps, <<"">>),
- 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 ->
- case couch_util:verify(ExpectedHash, Hash) of
- true ->
- 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">>, UserProps, []),
- user_doc=proplists:get_value(<<"user_doc">>, UserProps, null)
- }, auth={FullSecret, TimeLeft < Timeout*0.9}};
- _Else ->
- Req
- end;
- _Else ->
- Req
- end
+ ExpectedHash = crypto:sha_mac(Secret, 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 ->
+ case couch_util:verify(ExpectedHash, Hash) of
+ true ->
+ TimeLeft = TimeStamp + Timeout - CurrentTime,
+ ?LOG_DEBUG("Successful cookie auth as: ~p", [User]),
+ UserProps = case get_user(?l2b(User)) of
+ nil -> [];
+ Props -> Props
+ end,
+ Req#httpd{user_ctx=#user_ctx{
+ name=?l2b(User),
+ roles=proplists:get_value(<<"roles">>, UserProps, []),
+ user_doc=proplists:get_value(<<"user_doc">>, UserProps, null)
+ }, auth={Secret, TimeLeft < Timeout*0.9}};
+ _Else ->
+ Req
+ end;
+ _Else ->
+ Req
end
end
end.
@@ -356,7 +354,7 @@ handle_session_req(#httpd{method='POST', mochi_req=MochiReq}=Req) ->
Secret = ?l2b(ensure_cookie_auth_secret()),
{NowMS, NowS, _} = erlang:now(),
CurrentTime = NowMS * 1000000 + NowS,
- Cookie = cookie_auth_cookie(?b2l(UserName), <<Secret/binary, UserSalt/binary>>, CurrentTime),
+ Cookie = cookie_auth_cookie(?b2l(UserName), Secret, CurrentTime),
% TODO document the "next" feature in Futon
{Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of
nil ->