Compare commits

...

5 Commits

Author SHA1 Message Date
absc dcc7bc3ca3 Introduce the authentication handler.
The user handler will also receive the ability to answer with
JSON bodies soon.
2024-08-09 22:19:17 +00:00
absc 51675065a9 Pass the user for authentication with cookies in a cookie itself. 2024-08-09 20:37:47 +00:00
absc 6acd5e3a5a Move the cookie parameter as value. 2024-08-09 19:50:19 +00:00
absc 318ab0d301 Corrected module documentation. 2024-08-07 22:19:11 +00:00
absc ceea61c1ed Add the username to the JSON response to GET.
In addition to this, version the API with the classic
"api/v1" path.
2024-08-07 22:10:45 +00:00
6 changed files with 291 additions and 31 deletions

View File

@ -3,7 +3,7 @@
{vsn,"1.0.0"}, {vsn,"1.0.0"},
{modules,[dudeswave,dudeswave_app,dudeswave_handler, {modules,[dudeswave,dudeswave_app,dudeswave_handler,
dudeswave_user_handler,dudeswave_supervisor, dudeswave_user_handler,dudeswave_supervisor,
dudeswave_auth]}, dudeswave_auth,dudeswave_auth_handler]},
{registered,[]}, {registered,[]},
{applications,[kernel,stdlib,erts,cowboy,ranch]}, {applications,[kernel,stdlib,erts,cowboy,ranch]},
{mod,{dudeswave_app,[]}}, {mod,{dudeswave_app,[]}},

View File

@ -6,6 +6,7 @@ ERLC?= erlc -server
OBJS= dudeswave.beam dudeswave_app.beam OBJS= dudeswave.beam dudeswave_app.beam
OBJS+= dudeswave_supervisor.beam dudeswave_handler.beam OBJS+= dudeswave_supervisor.beam dudeswave_handler.beam
OBJS+= dudeswave_user_handler.beam dudeswave_auth.beam OBJS+= dudeswave_user_handler.beam dudeswave_auth.beam
OBJS+= dudeswave_auth_handler.beam
all: ${OBJS} all: ${OBJS}

View File

@ -40,7 +40,7 @@ start(_Type, StartArgs) ->
Dispatch = cowboy_router:compile([ Dispatch = cowboy_router:compile([
{'_', [ {'_', [
{"/user/:username", dudeswave_user_handler, #{bucket => ?USERSBUCK, {"/api/v1/user", dudeswave_user_handler, #{bucket => ?USERSBUCK,
cookies => ?COOKIESBUCK}}, cookies => ?COOKIESBUCK}},
{"/", dudeswave_handler, #{bucket => ?APPBUCK}} {"/", dudeswave_handler, #{bucket => ?APPBUCK}}
]} ]}

View File

@ -27,7 +27,7 @@ from the dudeswave database.
-include_lib("storage/include/storage.hrl"). -include_lib("storage/include/storage.hrl").
-export([authenticate/3, authenticate/4, details/2, new/4, -export([authenticate/3, authenticate/4, details/2, new/4,
update/5, delete/2]). update/5, delete/2, logout/3]).
-doc """ -doc """
Verify a session with an existing cookie. Verify a session with an existing cookie.
@ -55,11 +55,13 @@ authenticate(User, Cookie, Bucket) ->
case storage:read(Bucket, Cookie) of case storage:read(Bucket, Cookie) of
{ok, [R]} -> {ok, [R]} ->
CurTime = calendar:now_to_universal_time(erlang:timestamp()), CurTime = calendar:now_to_universal_time(erlang:timestamp()),
CookieTime = lists:keyfind(until, 1, R#object.metadata), CookieTime = R#object.value,
CookieUser = lists:keyfind(user, 1, R#object.metadata),
if if
CookieTime >= CurTime -> CookieTime >= CurTime ->
if if
User =:= R#object.value -> true; User =:= CookieUser -> true;
true -> false true -> false
end; end;
true -> false true -> false
@ -118,7 +120,7 @@ authenticate(User, Password, Cookies, Bucket) ->
if if
Auth =:= Hash -> Auth =:= Hash ->
Cookie = base64:encode(rand:bytes(64)), Cookie = base64:encode(rand:bytes(64)),
case storage:write(Cookies, <<Cookie/binary>>, User, [{until, Validity}]) of case storage:write(Cookies, <<Cookie/binary>>, Validity, [{user, User}]) of
ok -> {true, Cookie, Validity}; ok -> {true, Cookie, Validity};
{error, Reason} -> {error, Reason} {error, Reason} -> {error, Reason}
end; end;
@ -128,6 +130,38 @@ authenticate(User, Password, Cookies, Bucket) ->
{error, Reason} -> {error, Reason} {error, Reason} -> {error, Reason}
end. end.
-doc """
Close an existing session
Spec:
```
-spec logout(User, Cookie, Bucket) -> ok | {error, Reason} when
User :: binary(),
Cookie :: binary(),
Bucket :: atom(),
Reason :: term().
```
Invalidate and delete `Cookie` associated with `User` from the system.
""".
-spec logout(User, Cookie, Bucket) -> ok | {error, Reason} when
User :: binary(),
Cookie :: binary(),
Bucket :: atom(),
Reason :: term().
logout(User, Cookie, Bucket) ->
case storage:read(Bucket, Cookie) of
{ok, [R]} ->
{user, User} = lists:keyfind(user, 1, R#object.metadata),
storage:delete(Bucket, Cookie);
{ok, []} ->
{error, not_found};
{error, Reason} ->
{error, Reason}
end.
-doc """ -doc """
Return user details. Return user details.

View File

@ -0,0 +1,228 @@
%
% 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_handler).
-moduledoc """
JSON API to authenticate users.
```
/api/v1/auth
```
Cookies used in this module are:
```
dudename: The username.
dudeauth: Authentication cookie.
This module accepts four methods:
- POST /api/v1/auth
Authenticate a user with it's password. If successful, set the new
cookies with the authentication details in the browser.
- DELETE /api/v1/auth
Logout the user from the current session and invalidate all the
authentication cookies, if present.
If an operation fails, the response JSON is in the form:
```
{
"error": "error string"
}
```
JSON APIs
POST /api/v1/auth
```
{
"user": "foo",
"password": "SecurePassword123",
}
```
Response codes:
- 200 OK
- 400 Bad Request
- 404 Not Found
Response body:
If authentication successful:
```
{
"result": "ok"
}
```
DELETE /api/v1/auth
- 202 Accepted
- 404 Not Found
If operation successful;
```
{
"result": "deleted"
}
```
""".
-behaviour(cowboy_handler).
-export([init/2, terminate/3]).
%
% Callbacks exports
%
-export([allowed_methods/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, delete_completed/2,
login/2, logout/2]).
%
% Cowboy standard callbacks
%
init(Req, State) ->
{cowboy_rest, Req, State}.
known_methods(Req, State) ->
{[<<"POST">>, <<"DELETE">>], Req, State}.
allowed_methods(Req, State) ->
{[<<"POST">>, <<"DELETE">>], Req, State}.
is_authorized(Req, State) -> {true, Req, State}.
forbidden(Req, State) ->
case cowboy_req:method(Req) of
<<"POST">> ->
{false, Req, State};
_ ->
#{dudeauth := Auth, dudename := User} = cowboy_req:match_cookies([dudeauth,
dudename], Req),
{ok, Bucket} = maps:find(cookies, State),
case dudeswave_auth:authenticate(User, Auth, Bucket) of
{error, service_unavailable} -> exit(service_unavailable);
true -> {false, Req, State};
false ->
Resp = json:encode(#{<<"error">> => <<"authentication required">>}),
Req0 = cowboy_req:reply(403, #{}, Resp, Req),
{true, Req0, State}
end
end.
content_types_accepted(Req, State) ->
case cowboy_req:method(Req) of
<<"POST">> ->
{[{<<"application/json">>, login}], Req, State};
<<"DELETE">> ->
{[{<<"application/json">>, logout}], Req, State}
end.
resource_exists(Req, State) ->
#{dudename := User} = cowboy_req:match_cookies([dudename], Req),
{ok, Bucket} = maps:find(bucket, State),
case dudeswave_auth:user_details(User, Bucket) of
[] ->
Resp = json:encode(#{<<"error">> => <<"user does not exists">>}),
Req0 = cowboy_req:reply(404, #{}, Resp, Req),
{false, Req0, State};
{error, Reason} -> exit(Reason);
_ ->
NewState = State#{
bucket => Bucket,
user_exists => true
},
{true, Req, NewState}
end.
previously_existed(Req, State) -> {false, Req, State}.
is_conflict(Req, #{user_exists := true}) ->
{false, Req, []};
is_conflict(Req, State) -> {true, Req, State}.
allow_missing_post(Req, State) -> {false, Req, State}.
delete_resource(Req, State) ->
{ok, Bucket} = maps:find(bucket, State),
#{dudename := User, dudeauth := Auth} = cowboy_req:match_cookies([dudename,
dudeauth], Req),
case dudeswave_auth:logout(User, Auth, Bucket) of
ok ->
Req0 = cowboy_req:set_resp_cookie(<<"dudeauth">>, Auth, Req,
#{max_age => 0}),
Req1 = cowboy_req:set_resp_cookie(<<"dudename">>, User, Req0,
#{max_age => 0}),
Resp = json:encode(#{<<"result">> => <<"deleted">>}),
Req2 = cowboy_req:reply(200, #{}, Resp, Req1),
{true, Req2, State};
{error, _} -> {false, Req, State}
end.
delete_completed(Req, State) -> {false, Req, State}.
%
% Custom callbacks
%
login(Req, State) ->
{ok, Cookies} = maps:find(State, cookies),
{ok, Bucket} = maps:find(State, bucket),
{ok, Data, Req0} = cowboy_req:read_body(Req),
#{<<"user">> := User, <<"password">> := Pass} = json:decode(Data),
case dudeswave_auth:authenticate(User, Pass, Cookies, Bucket) of
{true, Cookie, Validity} ->
Resp = json:encode(#{<<"result">> => <<"ok">>}),
Req1 = cowboy_req:set_resp_cookie(<<"dudeauth">>, Cookie, Req0,
#{max_age => Validity}),
Req2 = cowboy_req:set_resp_cookie(<<"dudename">>, User, Req1,
#{max_age => Validity}),
Req3 = cowboy_req:reply(200, #{}, Resp, Req2),
{true, Req3, State};
false ->
Resp = json:encode(#{<<"error">> => <<"authentication failed">>}),
Req1 = cowboy_req:reply(401, #{}, Resp, Req0),
{false, Req1, State};
{error, _} ->
Resp = json:encode(#{<<"error">> => <<"internal error">>}),
Req1 = cowboy_req:reply(500, #{}, Resp, Req0),
{false, Req1, State}
end.
% Provided but not used
logout(Req, State) -> {ok, Req, State}.
%
% gen_server callbacks
%
terminate(_Reason, _Req, _State) -> ok.

View File

@ -17,33 +17,28 @@
-moduledoc """ -moduledoc """
JSON API to manage users. JSON API to manage users.
The username should be passed as one of the tokens of the request path, like The username is passed in a cookie. The handler recover it from the
session. Cookies are:
``` ```
/user/foo dudename # the actual username
/user/foo/details dudeauth # the authentication cookie
``` ```
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 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` the client. In case a technical problem occurs, `500 Internal Server Error`
is returned. is returned.
This module accepts four methods: This module accepts four methods:
- GET /user/:username - GET /api/v1/user
Retrieve user's details. However, this call requires the user to have Retrieve user's details. However, this call requires the user to have
a valid cookie set. Not suitable for a public page. a valid cookie set. Not suitable for a public page.
- POST /user/:username - POST /api/v1/user
Update user's details, like their name, description and whatnot. Update user's details, like their name, description and whatnot.
- DELETE /user/:username - DELETE /api/v1/user
Remove a user forever. The data is delete immediately. However, Remove a user forever. The data is delete immediately. However,
it's content is left up there. Probably a specific option will be added 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 later. This request does not have a body. The call deletes the user
@ -51,7 +46,7 @@ This module accepts four methods:
for the simple reason that we may make the call asynchronous for the simple reason that we may make the call asynchronous
to remove additional content in background. to remove additional content in background.
- PUT /user/:username - PUT /api/v1/user
Register a user. The registration takes only three parameter: username, Register a user. The registration takes only three parameter: username,
password and e-mail. The e-mail is required if a confirmation message 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 is to be sent. The plan is to have a separate process handle this, so the
@ -59,10 +54,11 @@ This module accepts four methods:
JSON APIs JSON APIs
GET /user/:username GET /api/v1/user
``` ```
{ {
"user" : "foo",
"email": "foo@example.com", "email": "foo@example.com",
"description": "A wonderful user", "description": "A wonderful user",
"name": "Fantastic Foo" "name": "Fantastic Foo"
@ -74,7 +70,7 @@ Response codes:
- 200 OK (Success) - 200 OK (Success)
- 404 Not Found - 404 Not Found
PUT /user/:username PUT /api/v1/user
``` ```
{ {
@ -89,7 +85,7 @@ Response codes:
- 400 Bad Request - 400 Bad Request
- 409 Conflict (User already exists) - 409 Conflict (User already exists)
POST /user/:username POST /api/v1/user
``` ```
{ {
@ -105,12 +101,11 @@ Response codes:
- 400 Bad Request - 400 Bad Request
- 404 Not Found - 404 Not Found
DELETE /user/:username DELETE /api/v1/user
- 202 Accepted - 202 Accepted
- 404 Not Found - 404 Not Found
""". """.
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
@ -146,9 +141,9 @@ forbidden(Req, State) ->
<<"PUT">> -> <<"PUT">> ->
{false, Req, State}; {false, Req, State};
_ -> _ ->
#{dudeauth := Auth} = cowboy_req:match_cookies([dudeauth], Req), #{dudeauth := Auth, dudename := User} = cowboy_req:match_cookies([dudeauth,
dudename], Req),
{ok, Bucket} = maps:find(cookies, State), {ok, Bucket} = maps:find(cookies, State),
User = cowboy_req:binding(username, Req),
case dudeswave_auth:authenticate({cookie, User, Auth}, Bucket) of case dudeswave_auth:authenticate({cookie, User, Auth}, Bucket) of
{error, service_unavailable} -> exit(service_unavailable); {error, service_unavailable} -> exit(service_unavailable);
@ -178,7 +173,7 @@ content_types_accepted(Req, State) ->
end. end.
resource_exists(Req, State) -> resource_exists(Req, State) ->
User = cowboy_req:binding(username, Req), #{dudename := User} = cowboy_req:match_cookies([dudename], Req),
{ok, Bucket} = maps:find(bucket, State), {ok, Bucket} = maps:find(bucket, State),
case dudeswave_auth:user_details(User, Bucket) of case dudeswave_auth:user_details(User, Bucket) of
@ -205,7 +200,7 @@ allow_missing_post(Req, State) -> {false, Req, State}.
delete_resource(Req, State) -> delete_resource(Req, State) ->
{ok, Bucket} = maps:find(bucket, State), {ok, Bucket} = maps:find(bucket, State),
User = cowboy_req:binding(username, Req), #{dudename := User} = cowboy_req:match_cookies([dudename], Req),
case dudeswave_auth:delete(User, Bucket) of case dudeswave_auth:delete(User, Bucket) of
ok -> {true, Req, State}; ok -> {true, Req, State};
@ -220,7 +215,7 @@ delete_completed(Req, State) -> {false, Req, State}.
create_user(Req, State) -> create_user(Req, State) ->
{ok, Bucket} = maps:find(bucket, State), {ok, Bucket} = maps:find(bucket, State),
User = cowboy_req:binding(username, Req), #{dudename := User} = cowboy_req:match_cookies([dudename], Req),
#{<<"password">> := Pass, <<"email">> := Email} = json:decode(cowboy_req:body(req)), #{<<"password">> := Pass, <<"email">> := Email} = json:decode(cowboy_req:body(req)),
@ -231,7 +226,7 @@ create_user(Req, State) ->
modify_user(Req, State) -> modify_user(Req, State) ->
{ok, Bucket} = maps:find(bucket, State), {ok, Bucket} = maps:find(bucket, State),
User = cowboy_req:binding(username, Req), #{dudename := User} = cowboy_req:match_cookies([dudename], Req),
#{<<"email">> := Email, <<"description">> := Desc, #{<<"email">> := Email, <<"description">> := Desc,
<<"name">> := Name} = json:decode(cowboy_req:body(req)), <<"name">> := Name} = json:decode(cowboy_req:body(req)),
@ -243,8 +238,10 @@ modify_user(Req, State) ->
user_details(Req, State) -> user_details(Req, State) ->
#{details := Details} = State, #{details := Details} = State,
#{dudename := User} = cowboy_req:match_cookies([dudename], Req),
Data = Details#{user => User},
{iolist_to_binary(json:encode(Details)), Req, State}. {iolist_to_binary(json:encode(Data)), Req, State}.
% %
% gen_server callbacks % gen_server callbacks