diff options
Diffstat (limited to 'server/src/jchat_auth.erl')
-rw-r--r-- | server/src/jchat_auth.erl | 433 |
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 + }. |