-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 '">>, 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, <>), 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, <>), ActualHash =:= ExpectedHash catch _:_ -> false end; _ -> false end. %% Hex encoding/decoding helpers hex_encode(Binary) -> << <<(hex_char(N div 16)), (hex_char(N rem 16))>> || <> <= Binary >>. hex_decode(Hex) -> << <<(hex_to_int(H1) * 16 + hex_to_int(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 }.