From dcc7bc3ca3fe55bf1ab261441d1dd5df6b47f6db Mon Sep 17 00:00:00 2001
From: absc
Date: Fri, 9 Aug 2024 22:19:17 +0000
Subject: [PATCH] Introduce the authentication handler.
The user handler will also receive the ability to answer with
JSON bodies soon.
---
dudeswave/ebin/dudeswave.app | 2 +-
dudeswave/src/Makefile | 1 +
dudeswave/src/dudeswave_auth.erl | 34 +++-
dudeswave/src/dudeswave_auth_handler.erl | 228 +++++++++++++++++++++++
4 files changed, 263 insertions(+), 2 deletions(-)
create mode 100644 dudeswave/src/dudeswave_auth_handler.erl
diff --git a/dudeswave/ebin/dudeswave.app b/dudeswave/ebin/dudeswave.app
index 1c249cd..bb5da71 100644
--- a/dudeswave/ebin/dudeswave.app
+++ b/dudeswave/ebin/dudeswave.app
@@ -3,7 +3,7 @@
{vsn,"1.0.0"},
{modules,[dudeswave,dudeswave_app,dudeswave_handler,
dudeswave_user_handler,dudeswave_supervisor,
- dudeswave_auth]},
+ dudeswave_auth,dudeswave_auth_handler]},
{registered,[]},
{applications,[kernel,stdlib,erts,cowboy,ranch]},
{mod,{dudeswave_app,[]}},
diff --git a/dudeswave/src/Makefile b/dudeswave/src/Makefile
index ca56d34..7378aae 100644
--- a/dudeswave/src/Makefile
+++ b/dudeswave/src/Makefile
@@ -6,6 +6,7 @@ ERLC?= erlc -server
OBJS= dudeswave.beam dudeswave_app.beam
OBJS+= dudeswave_supervisor.beam dudeswave_handler.beam
OBJS+= dudeswave_user_handler.beam dudeswave_auth.beam
+OBJS+= dudeswave_auth_handler.beam
all: ${OBJS}
diff --git a/dudeswave/src/dudeswave_auth.erl b/dudeswave/src/dudeswave_auth.erl
index ad45ef8..8b5e357 100644
--- a/dudeswave/src/dudeswave_auth.erl
+++ b/dudeswave/src/dudeswave_auth.erl
@@ -27,7 +27,7 @@ from the dudeswave database.
-include_lib("storage/include/storage.hrl").
-export([authenticate/3, authenticate/4, details/2, new/4,
- update/5, delete/2]).
+ update/5, delete/2, logout/3]).
-doc """
Verify a session with an existing cookie.
@@ -130,6 +130,38 @@ authenticate(User, Password, Cookies, Bucket) ->
{error, Reason} -> {error, Reason}
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 """
Return user details.
diff --git a/dudeswave/src/dudeswave_auth_handler.erl b/dudeswave/src/dudeswave_auth_handler.erl
new file mode 100644
index 0000000..2efb235
--- /dev/null
+++ b/dudeswave/src/dudeswave_auth_handler.erl
@@ -0,0 +1,228 @@
+%
+% 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_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.