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.