Packages

Auth layer for the Glimr web framework

Current section

Files

Jump to
glimr_auth src glimr_auth@auth.erl
Raw

src/glimr_auth@auth.erl

-module(glimr_auth@auth).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/glimr_auth/auth.gleam").
-export([login/3, logout/1, check/2, id/2, resolve_user/2, check_throttle/2, record_failure/4, clear_throttle/2]).
-export_type([auth_error/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Auth\n"
"\n"
" Controllers shouldn't use raw session keys like\n"
" \"_auth_user_id\" directly — that couples every auth check to\n"
" a magic string that could drift between modules. This\n"
" module wraps the session with semantic helpers (login,\n"
" logout, check, id) so auth logic reads clearly and the\n"
" session key is passed explicitly by the caller (typically\n"
" from the generated middleware's `session_key` constant).\n"
"\n"
).
-type auth_error() :: invalid_credentials | throttled.
-file("src/glimr_auth/auth.gleam", 40).
?DOC(
" Regenerating the session ID after storing the user ID is\n"
" critical — without it, an attacker who planted a known\n"
" session ID before authentication could hijack the post-\n"
" login session. The regenerate call rotates the ID while\n"
" preserving all session data so the user doesn't lose\n"
" pre-login state like a shopping cart.\n"
).
-spec login(glimr@session@session:session(), binary(), binary()) -> nil.
login(Session, User_id, Session_key) ->
glimr@session@session:put(Session, Session_key, User_id),
glimr@session@session:regenerate(Session).
-file("src/glimr_auth/auth.gleam", 52).
?DOC(
" Invalidate rather than just deleting the auth key — a\n"
" partial logout that leaves other session data intact could\n"
" leak state between users on shared devices. Invalidation\n"
" clears everything, generates a fresh ID, and tells the\n"
" middleware to delete the old entry from the store so it can\n"
" never be replayed.\n"
).
-spec logout(glimr@session@session:session()) -> nil.
logout(Session) ->
glimr@session@session:invalidate(Session).
-file("src/glimr_auth/auth.gleam", 62).
?DOC(
" A Bool check is the most common auth gate — middleware and\n"
" guards typically just need to know \"logged in or not\" to\n"
" decide whether to redirect. Returning Bool instead of the\n"
" full user ID keeps the call site clean when the ID itself\n"
" isn't needed for the decision.\n"
).
-spec check(glimr@session@session:session(), binary()) -> boolean().
check(Session, Session_key) ->
glimr@session@session:has(Session, Session_key).
-file("src/glimr_auth/auth.gleam", 72).
?DOC(
" Returns a Result so callers can distinguish \"not logged in\"\n"
" from a logged-in user with an empty string ID. Controllers\n"
" that need the actual user ID to load a profile or check\n"
" permissions use this, while simple auth gates use check\n"
" instead.\n"
).
-spec id(glimr@session@session:session(), binary()) -> {ok, binary()} |
{error, nil}.
id(Session, Session_key) ->
glimr@session@session:get(Session, Session_key).
-file("src/glimr_auth/auth.gleam", 83).
?DOC(
" Templates and context structs often use Option(String) for\n"
" the current user rather than Result — Option maps naturally\n"
" to \"present or absent\" while Result implies an operation\n"
" that could fail. This bridges the two so middleware can\n"
" populate ctx.user without unwrapping a Result at every call\n"
" site.\n"
).
-spec resolve_user(glimr@session@session:session(), binary()) -> gleam@option:option(binary()).
resolve_user(Session, Session_key) ->
_pipe = id(Session, Session_key),
gleam@option:from_result(_pipe).
-file("src/glimr_auth/auth.gleam", 98).
?DOC(
" Brute-force login attacks hammer the same session with\n"
" thousands of password guesses. Checking the attempt count\n"
" before even looking up the user means a locked-out session\n"
" gets rejected instantly without touching the database. If\n"
" the lockout window has expired, the counters are cleared so\n"
" the user can try again.\n"
).
-spec check_throttle(glimr@session@session:session(), binary()) -> {ok, nil} |
{error, auth_error()}.
check_throttle(Session, Session_key) ->
Attempts_key = <<Session_key/binary, "_attempts"/utf8>>,
Locked_until_key = <<Session_key/binary, "_locked_until"/utf8>>,
Throttled = begin
gleam@result:'try'(
glimr@session@session:get(Session, Locked_until_key),
fun(Locked_until_str) ->
gleam@result:'try'(
gleam_stdlib:parse_int(Locked_until_str),
fun(Locked_until) -> {ok, Locked_until} end
)
end
)
end,
case Throttled of
{error, _} ->
{ok, nil};
{ok, Locked_until@1} ->
case glimr@utils@unix_timestamp:now() < Locked_until@1 of
true ->
{error, throttled};
false ->
glimr@session@session:forget(Session, Attempts_key),
glimr@session@session:forget(Session, Locked_until_key),
{ok, nil}
end
end.
-file("src/glimr_auth/auth.gleam", 134).
?DOC(
" Each wrong password bumps a counter in the session. Once it\n"
" hits the max, a lockout timestamp is stored so\n"
" check_throttle can reject future attempts without any\n"
" database work. Storing this in the session rather than the\n"
" database means throttling works even for nonexistent\n"
" accounts — attackers can't enumerate valid emails by\n"
" observing different rate-limit behavior.\n"
).
-spec record_failure(
glimr@session@session:session(),
binary(),
integer(),
integer()
) -> nil.
record_failure(Session, Session_key, Max_attempts, Lockout_seconds) ->
Attempts_key = <<Session_key/binary, "_attempts"/utf8>>,
Locked_until_key = <<Session_key/binary, "_locked_until"/utf8>>,
Attempts = begin
gleam@result:'try'(
glimr@session@session:get(Session, Attempts_key),
fun(Str) -> gleam_stdlib:parse_int(Str) end
)
end,
Attempts@1 = case Attempts of
{ok, N} ->
N + 1;
{error, _} ->
1
end,
glimr@session@session:put(
Session,
Attempts_key,
erlang:integer_to_binary(Attempts@1)
),
case Attempts@1 >= Max_attempts of
true ->
Locked_until = glimr@utils@unix_timestamp:now() + Lockout_seconds,
glimr@session@session:put(
Session,
Locked_until_key,
erlang:integer_to_binary(Locked_until)
);
false ->
nil
end.
-file("src/glimr_auth/auth.gleam", 168).
?DOC(
" A successful login should reset the attempt counter —\n"
" otherwise a user who fat-fingered their password 4 times\n"
" then got it right would get locked out on their next typo\n"
" because the counter never reset.\n"
).
-spec clear_throttle(glimr@session@session:session(), binary()) -> nil.
clear_throttle(Session, Session_key) ->
Attempts_key = <<Session_key/binary, "_attempts"/utf8>>,
Locked_until_key = <<Session_key/binary, "_locked_until"/utf8>>,
glimr@session@session:forget(Session, Attempts_key),
glimr@session@session:forget(Session, Locked_until_key).