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.