aboutsummaryrefslogtreecommitdiff
path: root/server/src/jchat_auth.erl
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2025-09-03 21:15:36 -0400
committerCalvin Morrison <calvin@pobox.com>2025-09-03 21:15:36 -0400
commit49fa5aa2a127bdf8924d02bf77e5086b39c7a447 (patch)
tree61d86a7705dacc9fddccc29fa79d075d83ab8059 /server/src/jchat_auth.erl
i vibe coded itHEADmaster
Diffstat (limited to 'server/src/jchat_auth.erl')
-rw-r--r--server/src/jchat_auth.erl433
1 files changed, 433 insertions, 0 deletions
diff --git a/server/src/jchat_auth.erl b/server/src/jchat_auth.erl
new file mode 100644
index 0000000..36d9f39
--- /dev/null
+++ b/server/src/jchat_auth.erl
@@ -0,0 +1,433 @@
+-module(jchat_auth).
+
+-export([
+ authenticate_request/1,
+ register_user/3,
+ login_user/2,
+ validate_token/1,
+ generate_jwt/2,
+ hash_password/1,
+ verify_password/2,
+ get_user_by_id/1,
+ get_user_by_email/1
+]).
+
+-include("jchat.hrl").
+
+%% JWT secret - in production, load from config/environment
+-define(JWT_SECRET, <<"your-secret-key-change-this-in-production">>).
+-define(TOKEN_EXPIRY_HOURS, 24).
+
+%% Main authentication entry point
+authenticate_request(Req) ->
+ case cowboy_req:header(<<"authorization">>, Req) of
+ undefined ->
+ {error, #{
+ type => <<"unauthorized">>,
+ status => 401,
+ detail => <<"Missing Authorization header">>,
+ prompt => <<"register">> % Signal to show registration prompt
+ }};
+ AuthHeader ->
+ case jchat_utils:extract_auth_token(AuthHeader) of
+ {ok, Token} ->
+ validate_token(Token);
+ {error, invalid_auth_format} ->
+ {error, #{
+ type => <<"unauthorized">>,
+ status => 401,
+ detail => <<"Invalid Authorization header format. Use 'Bearer <token>'">>,
+ prompt => <<"register">>
+ }};
+ {error, no_auth_header} ->
+ {error, #{
+ type => <<"unauthorized">>,
+ status => 401,
+ detail => <<"Missing Authorization header">>,
+ prompt => <<"register">>
+ }}
+ end
+ end.
+
+%% Register a new user
+register_user(Email, Password, DisplayName) ->
+ case validate_registration_data(Email, Password, DisplayName) of
+ ok ->
+ case get_user_by_email(Email) of
+ {ok, _ExistingUser} ->
+ {error, #{
+ type => <<"userExists">>,
+ status => 400,
+ detail => <<"User with this email already exists">>
+ }};
+ {error, not_found} ->
+ UserId = jchat_utils:generate_id(),
+ PasswordHash = hash_password(Password),
+ User = #user{
+ id = UserId,
+ email = Email,
+ password_hash = PasswordHash,
+ display_name = DisplayName,
+ created_at = jchat_utils:now_iso8601(),
+ is_active = true,
+ auth_provider = <<"local">>,
+ auth_provider_id = null
+ },
+
+ case jchat_db:create_user(User) of
+ {ok, CreatedUser} ->
+ Token = generate_jwt(UserId, Email),
+ {ok, #{
+ <<"user">> => user_to_json(CreatedUser),
+ <<"token">> => Token,
+ <<"tokenType">> => <<"Bearer">>,
+ <<"expiresIn">> => ?TOKEN_EXPIRY_HOURS * 3600
+ }};
+ {error, Reason} ->
+ {error, #{
+ type => <<"serverFail">>,
+ status => 500,
+ detail => <<"Failed to create user: ", (iolist_to_binary(io_lib:format("~p", [Reason])))/binary>>
+ }}
+ end;
+ {error, Reason} ->
+ {error, #{
+ type => <<"serverFail">>,
+ status => 500,
+ detail => <<"Database error: ", (iolist_to_binary(io_lib:format("~p", [Reason])))/binary>>
+ }}
+ end;
+ {error, ValidationError} ->
+ {error, ValidationError}
+ end.
+
+%% Login existing user
+login_user(Email, Password) ->
+ case get_user_by_email(Email) of
+ {ok, User} ->
+ case verify_password(Password, User#user.password_hash) of
+ true ->
+ case User#user.is_active of
+ true ->
+ Token = generate_jwt(User#user.id, Email),
+ % Update last login time
+ UpdatedUser = User#user{last_login_at = jchat_utils:now_iso8601()},
+ jchat_db:update_user(UpdatedUser),
+ {ok, #{
+ <<"user">> => user_to_json(UpdatedUser),
+ <<"token">> => Token,
+ <<"tokenType">> => <<"Bearer">>,
+ <<"expiresIn">> => ?TOKEN_EXPIRY_HOURS * 3600
+ }};
+ false ->
+ {error, #{
+ type => <<"accountDisabled">>,
+ status => 403,
+ detail => <<"Account is disabled">>
+ }}
+ end;
+ false ->
+ {error, #{
+ type => <<"invalidCredentials">>,
+ status => 401,
+ detail => <<"Invalid email or password">>
+ }}
+ end;
+ {error, not_found} ->
+ {error, #{
+ type => <<"invalidCredentials">>,
+ status => 401,
+ detail => <<"Invalid email or password">>
+ }};
+ {error, Reason} ->
+ {error, #{
+ type => <<"serverFail">>,
+ status => 500,
+ detail => <<"Database error: ", (iolist_to_binary(io_lib:format("~p", [Reason])))/binary>>
+ }}
+ end.
+
+%% Validate JWT token
+validate_token(Token) ->
+ try
+ case jwt:decode(Token, ?JWT_SECRET) of
+ {ok, Claims} ->
+ case validate_token_claims(Claims) of
+ {ok, UserId, Email} ->
+ case get_user_by_id(UserId) of
+ {ok, User} ->
+ case User#user.is_active of
+ true ->
+ {ok, #{
+ user_id => UserId,
+ email => Email,
+ user => User
+ }};
+ false ->
+ {error, #{
+ type => <<"accountDisabled">>,
+ status => 403,
+ detail => <<"Account is disabled">>
+ }}
+ end;
+ {error, not_found} ->
+ {error, #{
+ type => <<"invalidToken">>,
+ status => 401,
+ detail => <<"User no longer exists">>
+ }};
+ {error, Reason} ->
+ {error, #{
+ type => <<"serverFail">>,
+ status => 500,
+ detail => <<"Database error: ", (iolist_to_binary(io_lib:format("~p", [Reason])))/binary>>
+ }}
+ end;
+ {error, Reason} ->
+ {error, #{
+ type => <<"invalidToken">>,
+ status => 401,
+ detail => Reason
+ }}
+ end;
+ {error, _Reason} ->
+ {error, #{
+ type => <<"invalidToken">>,
+ status => 401,
+ detail => <<"Invalid or malformed token">>,
+ prompt => <<"register">>
+ }}
+ end
+ catch
+ _:_ ->
+ {error, #{
+ type => <<"invalidToken">>,
+ status => 401,
+ detail => <<"Token validation failed">>,
+ prompt => <<"register">>
+ }}
+ end.
+
+%% Generate JWT token
+generate_jwt(UserId, Email) ->
+ Now = erlang:system_time(second),
+ Expiry = Now + (?TOKEN_EXPIRY_HOURS * 3600),
+ Claims = #{
+ <<"sub">> => UserId,
+ <<"email">> => Email,
+ <<"iat">> => Now,
+ <<"exp">> => Expiry,
+ <<"iss">> => <<"jchat-server">>
+ },
+ {ok, Token} = jwt:encode(<<"HS256">>, Claims, ?JWT_SECRET),
+ Token.
+
+%% Hash password using bcrypt with fallback
+hash_password(Password) ->
+ try
+ Salt = bcrypt:gen_salt(),
+ Hash = bcrypt:hashpw(Password, Salt),
+ <<"bcrypt$", Hash/binary>>
+ catch
+ _:_ ->
+ % Fallback to crypto-based hashing
+ logger:warning("bcrypt failed, using crypto fallback for password hashing"),
+ crypto_hash_password(Password)
+ end.
+
+%% Verify password against hash
+verify_password(Password, Hash) ->
+ case Hash of
+ <<"bcrypt$", BcryptHash/binary>> ->
+ try
+ bcrypt:verify(Password, BcryptHash)
+ catch
+ _:_ ->
+ logger:warning("bcrypt verify failed"),
+ false
+ end;
+ <<"crypto$", CryptoHash/binary>> ->
+ crypto_verify_password(Password, CryptoHash);
+ _ ->
+ % Legacy bcrypt hash without prefix
+ try
+ bcrypt:verify(Password, Hash)
+ catch
+ _:_ ->
+ false
+ end
+ end.
+
+%% Fallback crypto-based password hashing
+crypto_hash_password(Password) ->
+ Salt = crypto:strong_rand_bytes(16),
+ Hash = crypto:hash(sha256, <<Salt/binary, Password/binary>>),
+ SaltHex = hex_encode(Salt),
+ HashHex = hex_encode(Hash),
+ <<"crypto$", SaltHex/binary, "$", HashHex/binary>>.
+
+%% Verify crypto-based password
+crypto_verify_password(Password, CryptoHash) ->
+ case binary:split(CryptoHash, <<"$">>) of
+ [SaltHex, HashHex] ->
+ try
+ Salt = hex_decode(SaltHex),
+ ExpectedHash = hex_decode(HashHex),
+ ActualHash = crypto:hash(sha256, <<Salt/binary, Password/binary>>),
+ ActualHash =:= ExpectedHash
+ catch
+ _:_ ->
+ false
+ end;
+ _ ->
+ false
+ end.
+
+%% Hex encoding/decoding helpers
+hex_encode(Binary) ->
+ << <<(hex_char(N div 16)), (hex_char(N rem 16))>> || <<N>> <= Binary >>.
+
+hex_decode(Hex) ->
+ << <<(hex_to_int(H1) * 16 + hex_to_int(H2))>> || <<H1, H2>> <= Hex >>.
+
+hex_char(N) when N < 10 -> $0 + N;
+hex_char(N) -> $a + N - 10.
+
+hex_to_int(C) when C >= $0, C =< $9 -> C - $0;
+hex_to_int(C) when C >= $a, C =< $f -> C - $a + 10;
+hex_to_int(C) when C >= $A, C =< $F -> C - $A + 10.
+
+%% Get user by ID
+get_user_by_id(UserId) ->
+ jchat_db:get_user_by_id(UserId).
+
+%% Get user by email
+get_user_by_email(Email) ->
+ jchat_db:get_user_by_email(Email).
+
+%% Private helper functions
+
+validate_registration_data(Email, Password, DisplayName) ->
+ case validate_email(Email) of
+ ok ->
+ case validate_password(Password) of
+ ok ->
+ case validate_display_name(DisplayName) of
+ ok -> ok;
+ Error -> Error
+ end;
+ Error -> Error
+ end;
+ Error -> Error
+ end.
+
+validate_email(Email) when is_binary(Email) ->
+ EmailStr = binary_to_list(Email),
+ case re:run(EmailStr, "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$") of
+ {match, _} ->
+ case byte_size(Email) =< 255 of
+ true -> ok;
+ false -> {error, #{
+ type => <<"invalidArguments">>,
+ status => 400,
+ detail => <<"Email address is too long">>
+ }}
+ end;
+ nomatch ->
+ {error, #{
+ type => <<"invalidArguments">>,
+ status => 400,
+ detail => <<"Invalid email address format">>
+ }}
+ end;
+validate_email(_) ->
+ {error, #{
+ type => <<"invalidArguments">>,
+ status => 400,
+ detail => <<"Email must be a string">>
+ }}.
+
+validate_password(Password) when is_binary(Password) ->
+ case byte_size(Password) of
+ Size when Size >= 8, Size =< 128 ->
+ ok;
+ Size when Size < 8 ->
+ {error, #{
+ type => <<"invalidArguments">>,
+ status => 400,
+ detail => <<"Password must be at least 8 characters long">>
+ }};
+ _ ->
+ {error, #{
+ type => <<"invalidArguments">>,
+ status => 400,
+ detail => <<"Password is too long">>
+ }}
+ end;
+validate_password(_) ->
+ {error, #{
+ type => <<"invalidArguments">>,
+ status => 400,
+ detail => <<"Password must be a string">>
+ }}.
+
+validate_display_name(DisplayName) when is_binary(DisplayName) ->
+ case byte_size(DisplayName) of
+ Size when Size >= 1, Size =< 100 ->
+ % Check for valid characters (letters, numbers, spaces, basic punctuation)
+ case re:run(DisplayName, "^[a-zA-Z0-9 ._-]+$", [unicode]) of
+ {match, _} -> ok;
+ nomatch -> {error, #{
+ type => <<"invalidArguments">>,
+ status => 400,
+ detail => <<"Display name contains invalid characters">>
+ }}
+ end;
+ Size when Size < 1 ->
+ {error, #{
+ type => <<"invalidArguments">>,
+ status => 400,
+ detail => <<"Display name cannot be empty">>
+ }};
+ _ ->
+ {error, #{
+ type => <<"invalidArguments">>,
+ status => 400,
+ detail => <<"Display name is too long">>
+ }}
+ end;
+validate_display_name(_) ->
+ {error, #{
+ type => <<"invalidArguments">>,
+ status => 400,
+ detail => <<"Display name must be a string">>
+ }}.
+
+validate_token_claims(Claims) ->
+ try
+ UserId = maps:get(<<"sub">>, Claims),
+ Email = maps:get(<<"email">>, Claims),
+ Expiry = maps:get(<<"exp">>, Claims),
+ Now = erlang:system_time(second),
+
+ case Expiry > Now of
+ true ->
+ {ok, UserId, Email};
+ false ->
+ {error, <<"Token has expired">>}
+ end
+ catch
+ _:_ ->
+ {error, <<"Invalid token claims">>}
+ end.
+
+user_to_json(User) ->
+ #{
+ <<"id">> => User#user.id,
+ <<"email">> => User#user.email,
+ <<"displayName">> => User#user.display_name,
+ <<"createdAt">> => User#user.created_at,
+ <<"lastLoginAt">> => User#user.last_login_at,
+ <<"isActive">> => User#user.is_active,
+ <<"authProvider">> => User#user.auth_provider
+ }.