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.
main
absc 2024-08-07 01:17:35 +02:00
parent 2b995f67c8
commit b06e7d9f50
5 changed files with 455 additions and 53 deletions

View File

@ -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,[]}},

View File

@ -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}

View File

@ -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}}
]}
]),

View File

@ -0,0 +1,239 @@
%
% Copyright (c) 2024 Andrea Biscuola <a@abiscuola.com>
%
% 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, <<Password/binary, Salt/binary>>),
if
Auth =:= Hash ->
Cookie = base64:encode(rand:bytes(64)),
Until = calendar:now_to_universal_time(erlang:timestamp()),
case storage:write(Cookies, <<Cookie/binary>>, 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, <<Password/binary, Salt/binary>>),
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).

View File

@ -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, <<Pass/binary, Salt/binary>>),
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.