| % 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_lib("couch/include/couch_db.hrl"). |
| |
| -export([party_mode_handler/1]). |
| |
| -export([default_authentication_handler/1, default_authentication_handler/2, |
| special_test_authentication_handler/1]). |
| -export([cookie_authentication_handler/1, cookie_authentication_handler/2]). |
| -export([null_authentication_handler/1]). |
| -export([proxy_authentication_handler/1, proxy_authentification_handler/1]). |
| -export([cookie_auth_header/2]). |
| -export([handle_session_req/1, handle_session_req/2]). |
| |
| -export([authenticate/2, verify_totp/2, maybe_upgrade_password_hash/6]). |
| -export([ensure_cookie_auth_secret/0, make_cookie_time/0]). |
| -export([cookie_auth_cookie/4, cookie_scheme/1]). |
| -export([maybe_value/3]). |
| |
| -import(couch_httpd, [header_value/2, send_json/2,send_json/4, send_method_not_allowed/2]). |
| |
| -compile({no_auto_import,[integer_to_binary/1, integer_to_binary/2]}). |
| |
| party_mode_handler(Req) -> |
| case config:get("couch_httpd_auth", "require_valid_user", "false") of |
| "true" -> |
| throw({unauthorized, <<"Authentication required.">>}); |
| "false" -> |
| Req#httpd{user_ctx=#user_ctx{}} |
| end. |
| |
| 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}, {parts, 2}]), |
| 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=?ADMIN_USER} |
| end. |
| |
| basic_name_pw(Req) -> |
| AuthorizationHeader = header_value(Req, "Authorization"), |
| case AuthorizationHeader of |
| "Basic " ++ Base64Value -> |
| try re:split(base64:decode(Base64Value), ":", |
| [{return, list}, {parts, 2}]) of |
| ["_", "_"] -> |
| % special name and pass to be logged out |
| nil; |
| [User, Pass] -> |
| {User, Pass}; |
| _ -> |
| nil |
| catch |
| error:function_clause -> |
| throw({bad_request, "Authorization header has invalid base64 value"}) |
| end; |
| _ -> |
| nil |
| end. |
| |
| default_authentication_handler(Req) -> |
| default_authentication_handler(Req, couch_auth_cache). |
| |
| default_authentication_handler(Req, AuthModule) -> |
| case basic_name_pw(Req) of |
| {User, Pass} -> |
| case AuthModule:get_user_creds(Req, User) of |
| nil -> |
| throw({unauthorized, <<"Name or password is incorrect.">>}); |
| {ok, UserProps, AuthCtx} -> |
| reject_if_totp(UserProps), |
| UserName = ?l2b(User), |
| Password = ?l2b(Pass), |
| case authenticate(Password, UserProps) of |
| true -> |
| UserProps2 = maybe_upgrade_password_hash( |
| Req, UserName, Password, UserProps, |
| AuthModule, AuthCtx), |
| Req#httpd{user_ctx=#user_ctx{ |
| name=UserName, |
| roles=couch_util:get_value(<<"roles">>, UserProps2, []) |
| }}; |
| false -> |
| authentication_warning(Req, UserName), |
| throw({unauthorized, <<"Name or password is incorrect.">>}) |
| end |
| end; |
| nil -> |
| case couch_server:has_admins() of |
| true -> |
| Req; |
| false -> |
| case 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=?ADMIN_USER} |
| end |
| end |
| end. |
| |
| null_authentication_handler(Req) -> |
| Req#httpd{user_ctx=?ADMIN_USER}. |
| |
| %% @doc proxy auth handler. |
| % |
| % This handler allows creation of a userCtx object from a user authenticated remotly. |
| % The client just pass specific headers to CouchDB and the handler create the userCtx. |
| % Headers name can be defined in local.ini. By thefault they are : |
| % |
| % * X-Auth-CouchDB-UserName : contain the username, (x_auth_username in |
| % couch_httpd_auth section) |
| % * X-Auth-CouchDB-Roles : contain the user roles, list of roles separated by a |
| % comma (x_auth_roles in couch_httpd_auth section) |
| % * X-Auth-CouchDB-Token : token to authenticate the authorization (x_auth_token |
| % in couch_httpd_auth section). This token is an hmac-sha1 created from secret key |
| % and username. The secret key should be the same in the client and couchdb node. s |
| % ecret key is the secret key in couch_httpd_auth section of ini. This token is optional |
| % if value of proxy_use_secret key in couch_httpd_auth section of ini isn't true. |
| % |
| proxy_authentication_handler(Req) -> |
| case proxy_auth_user(Req) of |
| nil -> Req; |
| Req2 -> Req2 |
| end. |
| |
| %% @deprecated |
| proxy_authentification_handler(Req) -> |
| proxy_authentication_handler(Req). |
| |
| proxy_auth_user(Req) -> |
| XHeaderUserName = config:get("couch_httpd_auth", "x_auth_username", |
| "X-Auth-CouchDB-UserName"), |
| XHeaderRoles = config:get("couch_httpd_auth", "x_auth_roles", |
| "X-Auth-CouchDB-Roles"), |
| XHeaderToken = config:get("couch_httpd_auth", "x_auth_token", |
| "X-Auth-CouchDB-Token"), |
| case header_value(Req, XHeaderUserName) of |
| undefined -> nil; |
| UserName -> |
| Roles = case header_value(Req, XHeaderRoles) of |
| undefined -> []; |
| Else -> |
| [?l2b(R) || R <- string:tokens(Else, ",")] |
| end, |
| case config:get("couch_httpd_auth", "proxy_use_secret", "false") of |
| "true" -> |
| case config:get("couch_httpd_auth", "secret", undefined) of |
| undefined -> |
| Req#httpd{user_ctx=#user_ctx{name=?l2b(UserName), roles=Roles}}; |
| Secret -> |
| ExpectedToken = couch_util:to_hex(couch_crypto:hmac(sha, Secret, UserName)), |
| case header_value(Req, XHeaderToken) of |
| Token when Token == ExpectedToken -> |
| Req#httpd{user_ctx=#user_ctx{name=?l2b(UserName), |
| roles=Roles}}; |
| _ -> nil |
| end |
| end; |
| _ -> |
| Req#httpd{user_ctx=#user_ctx{name=?l2b(UserName), roles=Roles}} |
| end |
| end. |
| |
| |
| cookie_authentication_handler(Req) -> |
| cookie_authentication_handler(Req, couch_auth_cache). |
| |
| cookie_authentication_handler(#httpd{mochi_req=MochiReq}=Req, AuthModule) -> |
| case MochiReq:get_cookie_value("AuthSession") of |
| undefined -> Req; |
| [] -> Req; |
| Cookie -> |
| [User, TimeStr, HashStr] = try |
| AuthSession = couch_util:decodeBase64Url(Cookie), |
| [_A, _B, _Cs] = re:split(?b2l(AuthSession), ":", |
| [{return, list}, {parts, 3}]) |
| catch |
| _:_Error -> |
| Reason = <<"Malformed AuthSession cookie. Please clear your cookies.">>, |
| throw({bad_request, Reason}) |
| end, |
| % Verify expiry and hash |
| CurrentTime = make_cookie_time(), |
| case config:get("couch_httpd_auth", "secret", undefined) of |
| undefined -> |
| couch_log:debug("cookie auth secret is not set",[]), |
| Req; |
| SecretStr -> |
| Secret = ?l2b(SecretStr), |
| case AuthModule:get_user_creds(Req, User) of |
| nil -> Req; |
| {ok, UserProps, _AuthCtx} -> |
| UserSalt = couch_util:get_value(<<"salt">>, UserProps, <<"">>), |
| FullSecret = <<Secret/binary, UserSalt/binary>>, |
| ExpectedHash = couch_crypto:hmac(sha, FullSecret, User ++ ":" ++ TimeStr), |
| Hash = ?l2b(HashStr), |
| Timeout = list_to_integer( |
| config:get("couch_httpd_auth", "timeout", "600")), |
| couch_log:debug("timeout ~p", [Timeout]), |
| case (catch erlang:list_to_integer(TimeStr, 16)) of |
| TimeStamp when CurrentTime < TimeStamp + Timeout -> |
| case couch_passwords:verify(ExpectedHash, Hash) of |
| true -> |
| TimeLeft = TimeStamp + Timeout - CurrentTime, |
| couch_log:debug("Successful cookie auth as: ~p", |
| [User]), |
| Req#httpd{user_ctx=#user_ctx{ |
| name=?l2b(User), |
| roles=couch_util:get_value(<<"roles">>, UserProps, []) |
| }, auth={FullSecret, TimeLeft < Timeout*0.9}}; |
| _Else -> |
| Req |
| end; |
| _Else -> |
| Req |
| end |
| end |
| 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}}=Req, 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. |
| CookieHeader = couch_util:get_value("Set-Cookie", Headers, ""), |
| Cookies = mochiweb_cookies:parse_cookie(CookieHeader), |
| AuthSession = couch_util:get_value("AuthSession", Cookies), |
| if AuthSession == undefined -> |
| TimeStamp = make_cookie_time(), |
| [cookie_auth_cookie(Req, ?b2l(User), Secret, TimeStamp)]; |
| true -> |
| [] |
| end; |
| cookie_auth_header(_Req, _Headers) -> []. |
| |
| cookie_auth_cookie(Req, User, Secret, TimeStamp) -> |
| SessionData = User ++ ":" ++ erlang:integer_to_list(TimeStamp, 16), |
| Hash = couch_crypto:hmac(sha, Secret, SessionData), |
| mochiweb_cookies:cookie("AuthSession", |
| couch_util:encodeBase64Url(SessionData ++ ":" ++ ?b2l(Hash)), |
| [{path, "/"}] ++ cookie_scheme(Req) ++ max_age()). |
| |
| ensure_cookie_auth_secret() -> |
| case config:get("couch_httpd_auth", "secret", undefined) of |
| undefined -> |
| NewSecret = ?b2l(couch_uuids:random()), |
| config:set("couch_httpd_auth", "secret", NewSecret), |
| NewSecret; |
| Secret -> Secret |
| end. |
| |
| % session handlers |
| % Login handler with user db |
| handle_session_req(Req) -> |
| handle_session_req(Req, couch_auth_cache). |
| |
| handle_session_req(#httpd{method='POST', mochi_req=MochiReq}=Req, AuthModule) -> |
| ReqBody = MochiReq:recv_body(), |
| Form = case MochiReq:get_primary_header_value("content-type") of |
| % content type should be json |
| "application/x-www-form-urlencoded" ++ _ -> |
| mochiweb_util:parse_qs(ReqBody); |
| "application/json" ++ _ -> |
| {Pairs} = ?JSON_DECODE(ReqBody), |
| lists:map(fun({Key, Value}) -> |
| {?b2l(Key), ?b2l(Value)} |
| end, Pairs); |
| _ -> |
| [] |
| end, |
| UserName = ?l2b(extract_username(Form)), |
| Password = ?l2b(couch_util:get_value("password", Form, "")), |
| couch_log:debug("Attempt Login: ~s",[UserName]), |
| {ok, UserProps, AuthCtx} = case AuthModule:get_user_creds(Req, UserName) of |
| nil -> {ok, [], nil}; |
| Result -> Result |
| end, |
| case authenticate(Password, UserProps) of |
| true -> |
| verify_totp(UserProps, Form), |
| UserProps2 = maybe_upgrade_password_hash( |
| Req, UserName, Password, UserProps, AuthModule, AuthCtx), |
| % setup the session cookie |
| Secret = ?l2b(ensure_cookie_auth_secret()), |
| UserSalt = couch_util:get_value(<<"salt">>, UserProps2), |
| CurrentTime = make_cookie_time(), |
| Cookie = cookie_auth_cookie(Req, ?b2l(UserName), <<Secret/binary, UserSalt/binary>>, CurrentTime), |
| % TODO document the "next" feature in Futon |
| {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}, |
| {name, UserName}, |
| {roles, couch_util:get_value(<<"roles">>, UserProps2, [])} |
| ]}); |
| false -> |
| authentication_warning(Req, UserName), |
| % clear the session |
| Cookie = mochiweb_cookies:cookie("AuthSession", "", [{path, "/"}] ++ cookie_scheme(Req)), |
| {Code, Headers} = case couch_httpd:qs_value(Req, "fail", nil) of |
| nil -> |
| {401, [Cookie]}; |
| Redirect -> |
| {302, [Cookie, {"Location", couch_httpd:absolute_uri(Req, Redirect)}]} |
| end, |
| send_json(Req, Code, Headers, {[{error, <<"unauthorized">>},{reason, <<"Name or password is incorrect.">>}]}) |
| end; |
| % get user info |
| % GET /_session |
| handle_session_req(#httpd{method='GET', user_ctx=UserCtx}=Req, _AuthModule) -> |
| Name = UserCtx#user_ctx.name, |
| ForceLogin = couch_httpd:qs_value(Req, "basic", "false"), |
| case {Name, ForceLogin} of |
| {null, "true"} -> |
| throw({unauthorized, <<"Please login.">>}); |
| {Name, _} -> |
| send_json(Req, {[ |
| % remove this ok |
| {ok, true}, |
| {<<"userCtx">>, {[ |
| {name, Name}, |
| {roles, UserCtx#user_ctx.roles} |
| ]}}, |
| {info, {[ |
| {authentication_db, ?l2b(config:get("couch_httpd_auth", "authentication_db"))}, |
| {authentication_handlers, [ |
| N || {N, _Fun} <- Req#httpd.authentication_handlers]} |
| ] ++ maybe_value(authenticated, UserCtx#user_ctx.handler, fun(Handler) -> |
| Handler |
| end)}} |
| ]}) |
| end; |
| % logout by deleting the session |
| handle_session_req(#httpd{method='DELETE'}=Req, _AuthModule) -> |
| Cookie = mochiweb_cookies:cookie("AuthSession", "", [{path, "/"}] ++ cookie_scheme(Req)), |
| {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, _AuthModule) -> |
| send_method_not_allowed(Req, "GET,HEAD,POST,DELETE"). |
| |
| extract_username(Form) -> |
| CouchFormat = couch_util:get_value("name", Form), |
| case couch_util:get_value("username", Form, CouchFormat) of |
| undefined -> |
| throw({bad_request, <<"request body must contain a username">>}); |
| CouchFormat -> |
| CouchFormat; |
| Else1 when CouchFormat == undefined -> |
| Else1; |
| _Else2 -> |
| throw({bad_request, <<"request body contains different usernames">>}) |
| end. |
| |
| maybe_value(_Key, undefined, _Fun) -> []; |
| maybe_value(Key, Else, Fun) -> |
| [{Key, Fun(Else)}]. |
| |
| maybe_upgrade_password_hash(Req, UserName, Password, UserProps, |
| AuthModule, AuthCtx) -> |
| Upgrade = config:get_boolean("couch_httpd_auth", "upgrade_password_on_auth", true), |
| IsAdmin = lists:member(<<"_admin">>, couch_util:get_value(<<"roles">>, UserProps, [])), |
| case {IsAdmin, Upgrade, |
| couch_util:get_value(<<"password_scheme">>, UserProps, <<"simple">>)} of |
| {false, true, <<"simple">>} -> |
| UserProps2 = proplists:delete(<<"password_sha">>, UserProps), |
| UserProps3 = [{<<"password">>, Password} | UserProps2], |
| NewUserDoc = couch_doc:from_json_obj({UserProps3}), |
| ok = AuthModule:update_user_creds(Req, NewUserDoc, AuthCtx), |
| {ok, NewUserProps, _} = AuthModule:get_user_creds(Req, UserName), |
| NewUserProps; |
| _ -> |
| UserProps |
| end. |
| |
| authenticate(Pass, UserProps) -> |
| UserSalt = couch_util:get_value(<<"salt">>, UserProps, <<>>), |
| {PasswordHash, ExpectedHash} = |
| case couch_util:get_value(<<"password_scheme">>, UserProps, <<"simple">>) of |
| <<"simple">> -> |
| {couch_passwords:simple(Pass, UserSalt), |
| couch_util:get_value(<<"password_sha">>, UserProps, nil)}; |
| <<"pbkdf2">> -> |
| Iterations = couch_util:get_value(<<"iterations">>, UserProps, 10000), |
| verify_iterations(Iterations), |
| {couch_passwords:pbkdf2(Pass, UserSalt, Iterations), |
| couch_util:get_value(<<"derived_key">>, UserProps, nil)} |
| end, |
| couch_passwords:verify(PasswordHash, ExpectedHash). |
| |
| verify_iterations(Iterations) when is_integer(Iterations) -> |
| Min = list_to_integer(config:get("couch_httpd_auth", "min_iterations", "1")), |
| Max = list_to_integer(config:get("couch_httpd_auth", "max_iterations", "1000000000")), |
| case Iterations < Min of |
| true -> |
| throw({forbidden, <<"Iteration count is too low for this server">>}); |
| false -> |
| ok |
| end, |
| case Iterations > Max of |
| true -> |
| throw({forbidden, <<"Iteration count is too high for this server">>}); |
| false -> |
| ok |
| end. |
| |
| make_cookie_time() -> |
| {NowMS, NowS, _} = os:timestamp(), |
| NowMS * 1000000 + NowS. |
| |
| cookie_scheme(#httpd{mochi_req=MochiReq}) -> |
| [{http_only, true}] ++ |
| case MochiReq:get(scheme) of |
| http -> []; |
| https -> [{secure, true}] |
| end. |
| |
| max_age() -> |
| case config:get("couch_httpd_auth", "allow_persistent_cookies", "false") of |
| "false" -> |
| []; |
| "true" -> |
| Timeout = list_to_integer( |
| config:get("couch_httpd_auth", "timeout", "600")), |
| [{max_age, Timeout}] |
| end. |
| |
| reject_if_totp(User) -> |
| case get_totp_config(User) of |
| undefined -> |
| ok; |
| _ -> |
| throw({unauthorized, <<"Name or password is incorrect.">>}) |
| end. |
| |
| verify_totp(User, Form) -> |
| case get_totp_config(User) of |
| undefined -> |
| ok; |
| {Props} -> |
| Key = couch_base32:decode(couch_util:get_value(<<"key">>, Props)), |
| Alg = couch_util:to_existing_atom( |
| couch_util:get_value(<<"algorithm">>, Props, <<"sha">>)), |
| Len = couch_util:get_value(<<"length">>, Props, 6), |
| Token = ?l2b(couch_util:get_value("token", Form, "")), |
| verify_token(Alg, Key, Len, Token) |
| end. |
| |
| get_totp_config(User) -> |
| couch_util:get_value(<<"totp">>, User). |
| |
| verify_token(Alg, Key, Len, Token) -> |
| Now = make_cookie_time(), |
| Tokens = [generate_token(Alg, Key, Len, Now - 30), |
| generate_token(Alg, Key, Len, Now), |
| generate_token(Alg, Key, Len, Now + 30)], |
| %% evaluate all tokens in constant time |
| Match = lists:foldl(fun(T, Acc) -> couch_util:verify(T, Token) or Acc end, |
| false, Tokens), |
| case Match of |
| true -> |
| ok; |
| _ -> |
| throw({unauthorized, <<"Name or password is incorrect.">>}) |
| end. |
| |
| generate_token(Alg, Key, Len, Timestamp) -> |
| integer_to_binary(couch_totp:generate(Alg, Key, Timestamp, 30, Len), Len). |
| |
| integer_to_binary(Int, Len) when is_integer(Int), is_integer(Len) -> |
| Unpadded = case erlang:function_exported(erlang, integer_to_binary, 1) of |
| true -> |
| erlang:integer_to_binary(Int); |
| false -> |
| ?l2b(integer_to_list(Int)) |
| end, |
| Padding = binary:copy(<<"0">>, Len), |
| Padded = <<Padding/binary, Unpadded/binary>>, |
| binary:part(Padded, byte_size(Padded), -Len). |
| |
| authentication_warning(#httpd{mochi_req = Req}, User) -> |
| Peer = Req:get(peer), |
| couch_log:warning("~p: Authentication failed for user ~s from ~s", |
| [?MODULE, User, Peer]). |