From b06e7d9f50efd9bd990ac7ff4cfa916c687a045d Mon Sep 17 00:00:00 2001 From: absc Date: Wed, 7 Aug 2024 01:17:35 +0200 Subject: [PATCH] Complete the registration API. After some fight to decide on the right design, we finally have a module to handle users details. The API is JSON-based and it's documented on the top of the dudeswave_handler.erl file. The dudeswave_auth module, implements the functions that handle things at the storage layer, as we would like to reuse those capabilties in the future. To add, the "delete" call doesn't handle removing the cookies for the moment, It'll come with the next commit. Additionally, after that, a related test suite will be added, in order to start to test the whole thing locally. --- dudeswave/ebin/dudeswave.app | 3 +- dudeswave/src/Makefile | 2 +- dudeswave/src/dudeswave_app.erl | 12 +- dudeswave/src/dudeswave_auth.erl | 239 +++++++++++++++++++++ dudeswave/src/dudeswave_user_handler.erl | 252 ++++++++++++++++++----- 5 files changed, 455 insertions(+), 53 deletions(-) create mode 100644 dudeswave/src/dudeswave_auth.erl diff --git a/dudeswave/ebin/dudeswave.app b/dudeswave/ebin/dudeswave.app index 83da934..1c249cd 100644 --- a/dudeswave/ebin/dudeswave.app +++ b/dudeswave/ebin/dudeswave.app @@ -2,7 +2,8 @@ [{description,"The dudeswave web experience"}, {vsn,"1.0.0"}, {modules,[dudeswave,dudeswave_app,dudeswave_handler, - dudeswave_user_handler,dudeswave_supervisor]}, + dudeswave_user_handler,dudeswave_supervisor, + dudeswave_auth]}, {registered,[]}, {applications,[kernel,stdlib,erts,cowboy,ranch]}, {mod,{dudeswave_app,[]}}, diff --git a/dudeswave/src/Makefile b/dudeswave/src/Makefile index ba1c4ed..ca56d34 100644 --- a/dudeswave/src/Makefile +++ b/dudeswave/src/Makefile @@ -5,7 +5,7 @@ ERLC?= erlc -server OBJS= dudeswave.beam dudeswave_app.beam OBJS+= dudeswave_supervisor.beam dudeswave_handler.beam -OBJS+= dudeswave_user_handler.beam +OBJS+= dudeswave_user_handler.beam dudeswave_auth.beam all: ${OBJS} diff --git a/dudeswave/src/dudeswave_app.erl b/dudeswave/src/dudeswave_app.erl index 5525286..f5a106e 100644 --- a/dudeswave/src/dudeswave_app.erl +++ b/dudeswave/src/dudeswave_app.erl @@ -18,10 +18,13 @@ -export([bootstrap/3, start/2, stop/1]). --define(APPBUCKET, dudeswave). --define(USERSBUCKET, dudes). +-define(APPBUCK, dudeswave). +-define(USERSBUCK, dudes). +-define(COOKIESBUCK, cookies). start(_Type, StartArgs) -> + crypto:rand_seed(), + {ok, Addr} = case application:get_env(ip) of {ok, AddrConf} -> inet:parse_address(AddrConf); undefined -> undefined @@ -37,8 +40,9 @@ start(_Type, StartArgs) -> Dispatch = cowboy_router:compile([ {'_', [ - {"/user", dudeswave_newuser_handler, #{bucket => ?USERSBUCKET}}, - {"/", dudeswave_handler, #{bucket => ?APPBUCKET}} + {"/user/:username", dudeswave_user_handler, #{bucket => ?USERSBUCK, + cookies => ?COOKIESBUCK}}, + {"/", dudeswave_handler, #{bucket => ?APPBUCK}} ]} ]), diff --git a/dudeswave/src/dudeswave_auth.erl b/dudeswave/src/dudeswave_auth.erl new file mode 100644 index 0000000..13c336e --- /dev/null +++ b/dudeswave/src/dudeswave_auth.erl @@ -0,0 +1,239 @@ +% +% Copyright (c) 2024 Andrea Biscuola +% +% Permission to use, copy, modify, and distribute this software for any +% purpose with or without fee is hereby granted, provided that the above +% copyright notice and this permission notice appear in all copies. +% +% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +% +-module(dudeswave_auth). +-moduledoc """ +Dudes users management module. + +Here lives all the functions needed to create, update and delete users +from the dudeswave database. +""". + +-define(RANDBYTES, 32). + +-include_lib("storage/include/storage.hrl"). + +-export([authenticate/3, authenticate/4, details/2, new/4, + update/5, delete/2]). + +-doc """ +Verify a session with an existing cookie. + +Spec: + +``` +-spec authenticate(User, Cookie, Bucket) -> true | false | {error, Reason} when + User :: binary(), + Cookie :: binary(), + Bucket :: atom(), + Reason :: term(). +``` + +Check against che `Bucket` table, containing the list of current cookies, +if `Cookie` is still valid to authenticate `User` session. +""". +-spec authenticate(User, Cookie, Bucket) -> true | false | {error, Reason} when + User :: binary(), + Cookie :: binary(), + Bucket :: atom(), + Reason :: term(). + +authenticate(User, Cookie, Bucket) -> + case storage:read(Bucket, Cookie) of + {ok, [R]} -> + CurTime = calendar:now_to_universal_time(erlang:timestamp()), + CookieTime = lists:keyfind(until, 1, R#object.metadata), + if + CookieTime >= CurTime -> + if + User =:= R#object.value -> true; + true -> false + end; + true -> false + end; + {ok, []} -> false; + {error, _} -> {error, service_unavailable} + end. + +-doc """ +Authenticate a user and return a new cookie for the new session. + +Spec: + +``` +-spec authenticate(User, Password, Cookies, Bucket) -> {true, Cookie} | false | {error, Reason} when + User :: binary(), + Password :: binary(), + Cookies :: atom(), + Bucket :: atom(), + Cookie :: binary(), + Reason :: term(). +``` + +Authenticate `User` with `Password`, reading the user's details from `Bucket`. + +If the authentication is successful, a new cookie is generated and stored in +the `Cookies` bucket. The cookie is returned to the caller in a tuple `{true, Cookie}`, +otherwise `false` is returned and the authentication is denied. +""". +-spec authenticate(User, Password, Cookies, Bucket) -> {true, Cookie} | false | {error, Reason} when + User :: binary(), + Password :: binary(), + Cookies :: atom(), + Bucket :: atom(), + Cookie :: binary(), + Reason :: term(). + +authenticate(User, Password, Cookies, Bucket) -> + case storage:read(Bucket, User) of + {ok, [R]} -> + {ok, Hash} = lists:keyfind(hash, 1, R#object.metadata), + {ok, Salt} = lists:keyfind(salt, 1, R#object.metadata), + Auth = crypto:hash(sha256, <>), + if + Auth =:= Hash -> + Cookie = base64:encode(rand:bytes(64)), + Until = calendar:now_to_universal_time(erlang:timestamp()), + case storage:write(Cookies, <>, User, [{until, Until}]) of + ok -> {true, Cookie}; + {error, Reason} -> {error, Reason} + end; + true -> false + end; + {ok, []} -> false; + {error, Reason} -> {error, Reason} + end. + +-doc """ +Return user details. + +Spec: + +``` +-spec details(User, Bucket) -> Value | {error, Reason} when + User :: binary(), + Bucket :: atom(), + Value :: term(), + Reason :: term(). +``` +""". +-spec details(User, Bucket) -> Value | {error, Reason} when + User :: binary(), + Bucket :: atom(), + Value :: term(), + Reason :: term(). + +details(User, Bucket) -> + case storage:read(Bucket, User) of + {ok, [R]} -> R#object.value; + {error, Reason} -> {error, Reason}; + {ok, []} -> {error, not_found} + end. + +-doc """ +Create a new user. + +Spec: + +``` +-spec new(User, Password, Email, Bucket) -> ok | {error, Reason} when + User :: binary(), + Password :: binary(), + Email :: binary(), + Bucket :: atom(), + Reason :: term(). +``` + +The `User` is added to `Bucket`, that is, the users bucket for the application. +`Password` is salted and hashed with SHA256 before being stored. + +The new user is saved with a metadata `status` of `waiting_confirmation`, +based on the application settings, the confirmation method may vary. +""". +-spec new(User, Password, Email, Bucket) -> ok | {error, Reason} when + User :: binary(), + Password :: binary(), + Email :: binary(), + Bucket :: atom(), + Reason :: term(). + +new(User, Password, Email, Bucket) -> + Salt = rand:bytes(32), + Hash = crypto:hash(sha256, <>), + + Data = #{<<"email">> => Email}, + Metadata = [{salt, Salt}, {hash, Hash}, {status, waiting_confirmation}], + + storage:write(Bucket, User, Data, Metadata). + +-doc """ +Update user's details + +Spec: + +``` +-spec update(User, Name, Email, Desc, Bucket) -> ok | {error, Reason} when + User :: binary(), + Name :: binary(), + Email :: binary(), + Desc :: binary(), + Bucket :: atom(), + Reason :: term(). +``` + +The details apart from `User` are updated. The username itself is immutable +and cannot be modified. All the other fields, excluding the e-mail, are the +ones that can be seen in the public page. +""". +-spec update(User, Name, Email, Desc, Bucket) -> ok | {error, Reason} when + User :: binary(), + Name :: binary(), + Email :: binary(), + Desc :: binary(), + Bucket :: atom(), + Reason :: term(). + +update(User, Name, Email, Desc, Bucket) -> + {ok, CurData, Metadata} = case storage:read(Bucket, User) of + {ok, [R]} -> + {ok, R#object.value, R#object.metadata}; + {error, Reason} -> {error, Reason} + end, + + Data = CurData#{<<"email">> => Email, <<"name">> => Name, + <<"description">> => Desc}, + + storage:write(Bucket, User, Data, Metadata). + +-doc """ +Delete an existing user from the database. + +``` +-spec delete(User, Bucket) -> ok | {error, Reason} when + User :: binary(), + Bucket :: atom(), + Reason :: term(). +``` +""". +-spec delete(User, Bucket) -> ok | {error, Reason} when + User :: binary(), + Bucket :: atom(), + Reason :: term(). + +delete(User, Bucket) -> + % We are missing the cleanup of the cookies + % here. For that, we need to add at least another + % API to the storage layer. + storage:delete(Bucket, User). \ No newline at end of file diff --git a/dudeswave/src/dudeswave_user_handler.erl b/dudeswave/src/dudeswave_user_handler.erl index 344084d..48ac315 100644 --- a/dudeswave/src/dudeswave_user_handler.erl +++ b/dudeswave/src/dudeswave_user_handler.erl @@ -16,6 +16,101 @@ -module(dudeswave_user_handler). -moduledoc """ JSON API to manage users. + +The username should be passed as one of the tokens of the request path, like + +``` +/user/foo +/user/foo/details +``` + +However, the first form is preferred. + +The user parameter must be called `username` as this module expects it +in order to work properly. All the requests must be done with a valid +session cookie in order to work. + +If the session is not valid, all the requests will return `403 Forbidden` to +the client. In case a technical problem occurs, `500 Internal Server Error` +is returned. + +This module accepts four methods: + +- GET /user/:username + Retrieve user's details. However, this call requires the user to have + a valid cookie set. Not suitable for a public page. + +- POST /user/:username + Update user's details, like their name, description and whatnot. + +- DELETE /user/:username + Remove a user forever. The data is delete immediately. However, + it's content is left up there. Probably a specific option will be added + later. This request does not have a body. The call deletes the user + immediately, however we return `202 Accepted` in case of success, + for the simple reason that we may make the call asynchronous + to remove additional content in background. + +- PUT /user/:username + Register a user. The registration takes only three parameter: username, + password and e-mail. The e-mail is required if a confirmation message + is to be sent. The plan is to have a separate process handle this, so the + API just need to set the proper value for the user's `status` in it's metadata. + +JSON APIs + +GET /user/:username + +``` +{ + "email": "foo@example.com", + "description": "A wonderful user", + "name": "Fantastic Foo" +} +``` + +Response codes: + +- 200 OK (Success) +- 404 Not Found + +PUT /user/:username + +``` +{ + "email": "foo@example.com", + "password": "123456" +} +``` + +Response codes: + +- 201 Created +- 400 Bad Request +- 409 Conflict (User already exists) + +POST /user/:username + +``` +{ + "email": "foo@example.com", + "description": "A wonderful user", + "name": "Fantastic Foo" +} +``` + +Response codes: + +- 200 OK +- 400 Bad Request +- 404 Not Found + +DELETE /user/:username + +- 202 Accepted +- 404 Not Found + + """. -behaviour(cowboy_handler). @@ -25,71 +120,134 @@ JSON API to manage users. % % Callbacks exports % --export([allowed_methods/2, content_types_accepted/2, - known_methods/2, resource_exists/2, is_conflict/2, - previously_existed/2, allow_missing_post/2, create_user/2]). - - --include_lib("storage/include/storage.hrl"). - --define(RANDBYTES, 32). +-export([allowed_methods/2, content_types_provided/2, + content_types_accepted/2, known_methods/2, is_authorized/2, + forbidden/2, resource_exists/2, is_conflict/2, previously_existed/2, + allow_missing_post/2, delete_resource/2, create_user/2, modify_user/2, + delete_completed/2, user_details/2]). % -% Protocol functions +% Cowboy standard callbacks % init(Req, State) -> {cowboy_rest, Req, State}. -allowed_methods(Req, State) -> - {[<<"POST">>], Req, State}. - -content_types_accepted(Req, State) -> - {[{<<"application/x-www-form-urlencoded">>, create_user}], Req, State}. - known_methods(Req, State) -> - {[<<"POST">>], Req, State}. + {[<<"POST">>, <<"PUT">>, <<"DELETE">>, <<"GET">>], Req, State}. -resource_exists(Req, State) -> - {ok, Bucket} = maps:find(bucket, State), +allowed_methods(Req, State) -> + {[<<"POST">>, <<"PUT">>, <<"DELETE">>, <<"GET">>], Req, State}. - case cowboy:read_urlencoded_body(Req) of - {ok, [{name, Name}, {username, User}, {password, Password}], NewReq} -> - case storage:read(Bucket, User) of - {ok, [_R]} -> - {true, NewReq, user_exists}; - {ok, []} -> - {false, NewReq, {Bucket, [{name, Name}, - {username, User},{password, Password}]}} +is_authorized(Req, State) -> {true, Req, State}. + +forbidden(Req, State) -> + case cowboy_req:method(Req) of + <<"PUT">> -> + {false, Req, State}; + _ -> + #{dudeauth := Auth} = cowboy_req:match_cookies([dudeauth], Req), + {ok, Bucket} = maps:find(cookies, State), + User = cowboy_req:binding(username, Req), + + case dudeswave_auth:authenticate({cookie, User, Auth}, Bucket) of + {error, service_unavailable} -> exit(service_unavailable); + true -> {false, Req, State}; + false -> {true, Req, State} end end. -is_conflict(Req, user_exists) -> {true, Req, []}; +content_types_provided(Req, State) -> + case cowboy_req:method(Req) of + <<"PUT">> -> + {[{<<"application/json">>, create_user}], Req, State}; + <<"POST">> -> + {[{<<"application/json">>, modify_user}], Req, State}; + <<"DELETE">> -> + {[{<<"application/json">>, delete_user}], Req, State}; + <<"GET">> -> + {[{<<"application/json">>, user_details}], Req, State} + end. + +content_types_accepted(Req, State) -> + case cowboy_req:method(Req) of + <<"PUT">> -> + {[{<<"application/json">>, create_user}], Req, State}; + <<"POST">> -> + {[{<<"application/json">>, modify_user}], Req, State} + end. + +resource_exists(Req, State) -> + User = cowboy_req:binding(username, Req), + {ok, Bucket} = maps:find(bucket, State), + + case dudeswave_auth:user_details(User, Bucket) of + [] -> {false, Req, State}; + {error, Reason} -> exit(Reason); + Details -> + NewState = State#{ + bucket => Bucket, + details => Details, + user_exists => true, + request => cowboy_req:method(Req) + }, + {true, Req, NewState} + end. + +previously_existed(Req, State) -> {false, Req, State}. + +is_conflict(Req, #{user_exists := true, request := <<"PUT">>}) -> + {true, Req, []}; is_conflict(Req, State) -> {false, Req, State}. -previously_existed(Req, State) -> - {false, Req, State}. +allow_missing_post(Req, State) -> {false, Req, State}. -allow_missing_post(Req, State) -> - {true, Req, State}. +delete_resource(Req, State) -> + {ok, Bucket} = maps:find(bucket, State), + User = cowboy_req:binding(username, Req), -create_user(Req, {Bucket, [{name, Name}, {username, User}, {password, Pass}]}) -> - crypto:rand_seed(), - Salt = rand:bytes(32), - Hash = crypto:hash(sha256, <>), - - URI = uri_string:recompose(#{ - scheme => cowboy_req:scheme(Req), - host => cowboy_req:host(Req), - path => lists:flatten(["/user/", User]) - }), - - case storage:write(Bucket, User, Hash, [{salt, Salt}, {name, Name}]) of - ok -> - {{true, list_to_binary(URI)}, Req, []}; - {error, Reason} -> - {false, Req, Reason} + case dudeswave_auth:delete(User, Bucket) of + ok -> {true, Req, State}; + {error, _} -> {false, Req, State} end. +delete_completed(Req, State) -> {false, Req, State}. + +% +% Custom callbacks +% + +create_user(Req, State) -> + {ok, Bucket} = maps:find(bucket, State), + User = cowboy_req:binding(username, Req), + + #{<<"password">> := Pass, <<"email">> := Email} = json:decode(cowboy_req:body(req)), + + case dudeswave_auth:new_user(User, Pass, Email, Bucket) of + ok -> {true, Req, []}; + {error, Reason} -> {false, Req, Reason} + end. + +modify_user(Req, State) -> + {ok, Bucket} = maps:find(bucket, State), + User = cowboy_req:binding(username, Req), + + #{<<"email">> := Email, <<"description">> := Desc, + <<"name">> := Name} = json:decode(cowboy_req:body(req)), + + case dudeswave_auth:update(User, Name, Email, Desc, Bucket) of + ok -> {true, Req, []}; + {error, Reason} -> {false, Req, Reason} + end. + +user_details(Req, State) -> + #{details := Details} = State, + + {iolist_to_binary(json:encode(Details)), Req, State}. + +% +% gen_server callbacks +% + terminate(_Reason, _Req, _State) -> ok.