diff --git a/src/handlers/handler_login.erl b/src/handlers/handler_login.erl index 6b0a478..8cea354 100644 --- a/src/handlers/handler_login.erl +++ b/src/handlers/handler_login.erl @@ -1,106 +1,106 @@ -%%%------------------------------------------------------------------- -%%% @doc Обработчик входа пользователя (клиентский API). -%%% POST – аутентифицирует пользователя по email и паролю, -%%% возвращает JWT токен и refresh токен. -%%% @end -%%%------------------------------------------------------------------- --module(handler_login). --behaviour(cowboy_handler). - --export([init/2]). --export([trails/0]). - --include("records.hrl"). - -%%% cowboy_handler callback --spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -init(Req, Opts) -> - handle(Req, Opts). - -%%% Swagger metadata --spec trails() -> [map()]. -trails() -> - [ - #{ - path => <<"/v1/login">>, - method => <<"POST">>, - description => <<"User login">>, - tags => [<<"Auth">>], - requestBody => #{ - required => true, - content => #{<<"application/json">> => #{schema => #{ - type => object, - required => [<<"email">>, <<"password">>], - properties => #{ - email => #{type => string, format => <<"email">>}, - password => #{type => string, format => <<"password">>} - } - }}} - }, - responses => #{ - 200 => #{description => <<"Login successful, returns token and user info">>}, - 400 => #{description => <<"Missing email or password, or invalid JSON">>}, - 401 => #{description => <<"Invalid credentials">>}, - 403 => #{description => <<"Account frozen or deleted">>} - } - } - ]. - -%%%=================================================================== -%%% HTTP-методы -%%%=================================================================== - -%% @private --spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -handle(Req, _Opts) -> - case cowboy_req:method(Req) of - <<"POST">> -> login(Req); - _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) - end. - -%% @doc POST /v1/login — аутентификация пользователя. --spec login(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -login(Req) -> - case cowboy_req:has_body(Req) of - true -> - {ok, Body, Req1} = cowboy_req:read_body(Req), - case Body of - <<>> -> - handler_utils:send_error(Req1, 400, <<"Empty request body">>); - _ -> - try jsx:decode(Body, [return_maps]) of - #{<<"email">> := Email, <<"password">> := Password} -> - case eventhub_auth:authenticate_user_request(Req1, Email, Password) of - {ok, Token, User} -> - UserId = maps:get(id, User), - {RefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId), - core_session:create(UserId, RefreshToken), - core_user:update_last_login(UserId), - Response = #{ - <<"token">> => Token, - <<"user">> => #{ - <<"id">> => UserId, - <<"email">> => maps:get(email, User), - <<"role">> => maps:get(role, User) - }, - <<"refresh_token">> => RefreshToken - }, - handler_utils:send_json(Req1, 200, Response); - {error, not_verified} -> - handler_utils:send_error(Req1, 403, <<"Account not verified">>); - {error, frozen} -> - handler_utils:send_error(Req1, 403, <<"Account frozen">>); - {error, deleted} -> - handler_utils:send_error(Req1, 403, <<"Account deleted">>); - {error, _Reason} -> - handler_utils:send_error(Req1, 401, <<"Invalid credentials">>) - end; - _ -> - handler_utils:send_error(Req1, 400, <<"Missing email or password">>) - catch - _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) - end - end; - false -> - handler_utils:send_error(Req, 400, <<"Missing request body">>) +%%%------------------------------------------------------------------- +%%% @doc Обработчик входа пользователя (клиентский API). +%%% POST – аутентифицирует пользователя по email и паролю, +%%% возвращает JWT токен и refresh токен. +%%% @end +%%%------------------------------------------------------------------- +-module(handler_login). +-behaviour(cowboy_handler). + +-export([init/2]). +-export([trails/0]). + +-include("records.hrl"). + +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +init(Req, Opts) -> + handle(Req, Opts). + +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/login">>, + method => <<"POST">>, + description => <<"User login">>, + tags => [<<"Auth">>], + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => #{ + type => object, + required => [<<"email">>, <<"password">>], + properties => #{ + email => #{type => string, format => <<"email">>}, + password => #{type => string, format => <<"password">>} + } + }}} + }, + responses => #{ + 200 => #{description => <<"Login successful, returns token and user info">>}, + 400 => #{description => <<"Missing email or password, or invalid JSON">>}, + 401 => #{description => <<"Invalid credentials">>}, + 403 => #{description => <<"Account frozen or deleted">>} + } + } + ]. + +%%%=================================================================== +%%% HTTP-методы +%%%=================================================================== + +%% @private +-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> login(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +%% @doc POST /v1/login — аутентификация пользователя. +-spec login(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +login(Req) -> + case cowboy_req:has_body(Req) of + true -> + {ok, Body, Req1} = cowboy_req:read_body(Req), + case Body of + <<>> -> + handler_utils:send_error(Req1, 400, <<"Empty request body">>); + _ -> + try jsx:decode(Body, [return_maps]) of + #{<<"email">> := Email, <<"password">> := Password} -> + case eventhub_auth:authenticate_user_request(Req1, Email, Password) of + {ok, _Token, User} -> + UserId = maps:get(id, User), + Role = maps:get(role, User), + {ok, Token, RefreshToken} = logic_auth_session:issue_user_tokens(UserId, Role), + core_user:update_last_login(UserId), + Response = #{ + <<"token">> => Token, + <<"user">> => #{ + <<"id">> => UserId, + <<"email">> => maps:get(email, User), + <<"role">> => maps:get(role, User) + }, + <<"refresh_token">> => RefreshToken + }, + handler_utils:send_json(Req1, 200, Response); + {error, not_verified} -> + handler_utils:send_error(Req1, 403, <<"Account not verified">>); + {error, frozen} -> + handler_utils:send_error(Req1, 403, <<"Account frozen">>); + {error, deleted} -> + handler_utils:send_error(Req1, 403, <<"Account deleted">>); + {error, _Reason} -> + handler_utils:send_error(Req1, 401, <<"Invalid credentials">>) + end; + _ -> + handler_utils:send_error(Req1, 400, <<"Missing email or password">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) + end + end; + false -> + handler_utils:send_error(Req, 400, <<"Missing request body">>) end. \ No newline at end of file diff --git a/src/handlers/handler_refresh.erl b/src/handlers/handler_refresh.erl index 70a886d..bd6ac39 100644 --- a/src/handlers/handler_refresh.erl +++ b/src/handlers/handler_refresh.erl @@ -1,102 +1,95 @@ -%%%------------------------------------------------------------------- -%%% @doc Обработчик обновления токена (клиентский API). -%%% POST – принимает refresh_token, возвращает новую пару access + refresh токенов. -%%% @end -%%%------------------------------------------------------------------- --module(handler_refresh). --behaviour(cowboy_handler). --export([init/2]). --export([trails/0]). - --include("records.hrl"). - -%%% cowboy_handler callback --spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -init(Req, _Opts) -> - case cowboy_req:method(Req) of - <<"POST">> -> refresh(Req); - _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) - end. - -%%% Swagger metadata --spec trails() -> [map()]. -trails() -> - [ - #{ - path => <<"/v1/auth/refresh">>, - method => <<"POST">>, - description => <<"Refresh user access token using refresh token">>, - tags => [<<"Auth">>], - requestBody => #{ - required => true, - content => #{ - <<"application/json">> => #{ - schema => #{ - type => object, - required => [<<"refresh_token">>], - properties => #{refresh_token => #{type => string}} - } - } - } - }, - responses => #{ - 200 => #{ - description => <<"New user token pair (access + refresh)">>, - content => #{ - <<"application/json">> => #{ - schema => token_pair_schema() - } - } - }, - 400 => #{description => <<"Missing refresh_token field or invalid JSON">>}, - 401 => #{description => <<"Refresh token expired or not found">>} - } - } - ]. - -token_pair_schema() -> - #{ - type => object, - properties => #{ - token => #{type => string, description => <<"JWT access token">>}, - refresh_token => #{type => string, description => <<"Refresh token">>} - } - }. - -%%%=================================================================== -%%% HTTP-методы -%%%=================================================================== - -%% @doc POST /v1/auth/refresh — обновление пользовательского токена. --spec refresh(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -refresh(Req) -> - {ok, Body, Req1} = cowboy_req:read_body(Req), - try jsx:decode(Body, [return_maps]) of - #{<<"refresh_token">> := RefreshToken} -> - case core_session:validate(RefreshToken) of - {ok, UserId, User} -> - user_refresh(UserId, User, RefreshToken, Req1); - {error, not_found} -> - handler_utils:send_error(Req1, 401, <<"Refresh token not found">>); - {error, expired} -> - handler_utils:send_error(Req1, 401, <<"Refresh token expired">>) - end; - _ -> - handler_utils:send_error(Req1, 400, <<"Missing refresh_token field">>) - catch - _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) - end. - -%%%=================================================================== -%%% Внутренние функции -%%%=================================================================== - -%% @private Обновляет токен для обычного пользователя. --spec user_refresh(binary(), #user{}, binary(), cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -user_refresh(UserId, User, OldToken, Req) -> - core_session:delete(OldToken), - Role = atom_to_binary(User#user.role, utf8), - NewToken = eventhub_auth:generate_user_token(UserId, Role), - {NewRefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId), - core_session:create(UserId, NewRefreshToken), - handler_utils:send_json(Req, 200, #{token => NewToken, refresh_token => NewRefreshToken}). \ No newline at end of file +%%%------------------------------------------------------------------- +%%% @doc Обработчик обновления токена (клиентский API). +%%% POST – принимает refresh_token, возвращает новую пару access + refresh токенов. +%%% @end +%%%------------------------------------------------------------------- +-module(handler_refresh). +-behaviour(cowboy_handler). +-export([init/2]). +-export([trails/0]). + +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> refresh(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/refresh">>, + method => <<"POST">>, + description => <<"Refresh user access token using refresh token">>, + tags => [<<"Auth">>], + requestBody => #{ + required => true, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => object, + required => [<<"refresh_token">>], + properties => #{refresh_token => #{type => string}} + } + } + } + }, + responses => #{ + 200 => #{ + description => <<"New user token pair (access + refresh)">>, + content => #{ + <<"application/json">> => #{ + schema => token_pair_schema() + } + } + }, + 400 => #{description => <<"Missing refresh_token field or invalid JSON">>}, + 401 => #{description => <<"Refresh token expired or not found">>} + } + } + ]. + +token_pair_schema() -> + #{ + type => object, + properties => #{ + token => #{type => string, description => <<"JWT access token">>}, + refresh_token => #{type => string, description => <<"Refresh token">>} + } + }. + +%%%=================================================================== +%%% HTTP-методы +%%%=================================================================== + +%% @doc POST /v1/refresh — обновление пользовательского токена. +-spec refresh(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +refresh(Req) -> + {ok, Body, Req1} = cowboy_req:read_body(Req), + try jsx:decode(Body, [return_maps]) of + #{<<"refresh_token">> := RefreshToken} -> + case logic_auth_session:refresh_user(RefreshToken) of + {ok, NewToken, NewRefreshToken} -> + handler_utils:send_json(Req1, 200, #{ + token => NewToken, + refresh_token => NewRefreshToken + }); + {error, reuse_detected} -> + handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>); + {error, not_found} -> + handler_utils:send_error(Req1, 401, <<"Refresh token not found">>); + {error, expired} -> + handler_utils:send_error(Req1, 401, <<"Refresh token expired">>); + {error, revoked} -> + handler_utils:send_error(Req1, 401, <<"Refresh token revoked">>); + {error, _} -> + handler_utils:send_error(Req1, 401, <<"Invalid refresh token">>) + end; + _ -> + handler_utils:send_error(Req1, 400, <<"Missing refresh_token field">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) + end. diff --git a/src/infra/eventhub_auth.erl b/src/infra/eventhub_auth.erl index f790b88..d224c20 100644 --- a/src/infra/eventhub_auth.erl +++ b/src/infra/eventhub_auth.erl @@ -1,245 +1,264 @@ --module(eventhub_auth). --export([ - generate_user_token/2, - generate_admin_token/2, - verify_user_token/1, - verify_admin_token/1, - authenticate_user_request/3, - authenticate_admin_request/3, - generate_refresh_token/1, - generate_admin_refresh_token/5, - verify_admin_refresh_token/1 -]). - -%% ========== КОНФИГУРАЦИЯ СЕКРЕТОВ ========== - --spec get_user_secret() -> binary(). -get_user_secret() -> - case application:get_env(eventhub, jwt_secret) of - {ok, Secret} when is_binary(Secret) -> Secret; - undefined -> get_user_secret_from_env() - end. - -get_user_secret_from_env() -> - case os:getenv("JWT_SECRET") of - false -> <<"user-secret-key-32-bytes-minimum!">>; - S -> list_to_binary(S) - end. - --spec get_admin_secret() -> binary(). -get_admin_secret() -> - case application:get_env(eventhub, admin_jwt_secret) of - {ok, Secret} when is_binary(Secret) -> Secret; - undefined -> get_admin_secret_from_env() - end. - -get_admin_secret_from_env() -> - case os:getenv("ADMIN_JWT_SECRET") of - false -> <<"admin-secret-key-32-bytes-minimum!">>; - S -> list_to_binary(S) - end. - --spec get_user_jwk() -> jose_jwk:key(). -get_user_jwk() -> jose_jwk:from_oct(get_user_secret()). - --spec get_admin_jwk() -> jose_jwk:key(). -get_admin_jwk() -> jose_jwk:from_oct(get_admin_secret()). - -%% ========== ГЕНЕРАЦИЯ ТОКЕНОВ ========== - --spec generate_user_token(UserId :: binary(), Role :: binary()) -> binary(). -generate_user_token(UserId, Role) -> - generate_token(get_user_jwk(), UserId, Role, <<"user">>). - --spec generate_admin_token(UserId :: binary(), Role :: binary()) -> binary(). -generate_admin_token(UserId, Role) -> - generate_token(get_admin_jwk(), UserId, Role, <<"admin">>). - -generate_token(JWK, UserId, Role, Audience) -> - ExpTime = erlang:system_time(second) + 86400, - Claims = #{ - <<"user_id">> => UserId, - <<"role">> => Role, - <<"aud">> => Audience, - <<"exp">> => ExpTime, - <<"iat">> => erlang:system_time(second) - }, - JWT = jose_jwt:sign(JWK, #{<<"alg">> => <<"HS256">>}, Claims), - {_, Token} = jose_jws:compact(JWT), - Token. - -%% ========== ПРОВЕРКА ТОКЕНОВ ========== - --spec verify_user_token(Token :: binary()) -> - {ok, UserId :: binary(), Role :: binary()} | {error, atom()}. -verify_user_token(Token) -> - verify_token(get_user_jwk(), Token, <<"user">>). - --spec verify_admin_token(Token :: binary()) -> - {ok, UserId :: binary(), Role :: binary()} | {error, atom()}. -verify_admin_token(Token) -> - verify_token(get_admin_jwk(), Token, <<"admin">>). - -verify_token(JWK, Token, ExpectedAud) -> - try - case jose_jwt:verify(JWK, Token) of - {true, {jose_jwt, Claims}, _} -> - validate_claims(Claims, ExpectedAud); - {true, Claims, _} when is_map(Claims) -> - validate_claims(Claims, ExpectedAud); - _ -> - {error, invalid_signature} - end - catch - _:_ -> {error, invalid_token} - end. - -validate_claims(Claims, ExpectedAud) -> - case maps:find(<<"aud">>, Claims) of - {ok, ExpectedAud} -> - case maps:find(<<"exp">>, Claims) of - {ok, Exp} when is_integer(Exp) -> - Now = erlang:system_time(second), - if - Exp > Now -> - UserId = maps:get(<<"user_id">>, Claims, undefined), - Role = maps:get(<<"role">>, Claims, <<"user">>), - {ok, UserId, Role}; - true -> - {error, expired} - end; - {ok, _Exp} -> {error, expired}; - _ -> {error, no_expiration} - end; - {ok, _} -> {error, invalid_audience}; - error -> {error, missing_audience} - end. - -%% ========== АУТЕНТИФИКАЦИЯ ЗАПРОСА ========== - --spec authenticate_user_request(Req :: cowboy_req:req(), Email :: binary(), Password :: binary()) -> - {ok, Token :: binary(), User :: map()} | {error, atom()}. -authenticate_user_request(_Req, Email, Password) -> - case logic_auth:authenticate_user(Email, Password) of - {ok, User} -> - UserId = maps:get(id, User), - Role = maps:get(role, User, <<"user">>), - Token = generate_user_token(UserId, Role), - {ok, Token, User}; - Error -> Error - end. - --spec authenticate_admin_request(Req :: cowboy_req:req(), Email :: binary(), Password :: binary()) -> - {ok, Token :: binary(), User :: map()} | {error, atom()}. -authenticate_admin_request(_Req, Email, Password) -> - case logic_auth:authenticate_admin(Email, Password) of - {ok, AdminMap} -> - Role = maps:get(role, AdminMap, <<"admin">>), - case is_admin_role(Role) of - true -> - AdminId = maps:get(id, AdminMap), - Token = generate_admin_token(AdminId, Role), - {ok, Token, AdminMap}; - false -> {error, insufficient_permissions} - end; - Error -> Error - end. - -is_admin_role(Role) -> - lists:member(Role, [<<"admin">>, <<"superadmin">>, <<"moderator">>, <<"support">>]). - --define(REFRESH_TTL_SECONDS, 30 * 24 * 3600). - -%% ========== REFRESH TOKEN (opaque, legacy user flow — фаза 2) ========== - --spec generate_refresh_token(UserId :: binary()) -> {binary(), calendar:datetime()}. -generate_refresh_token(_UserId) -> - RefreshToken = base64:encode(crypto:strong_rand_bytes(32)), - Now = calendar:universal_time(), - ExpiresAt = calendar:gregorian_seconds_to_datetime( - calendar:datetime_to_gregorian_seconds(Now) + ?REFRESH_TTL_SECONDS - ), - {RefreshToken, ExpiresAt}. - -%% ========== REFRESH JWT (единая session-модель) ========== - --spec generate_admin_refresh_token( - SessionId :: binary(), - FamilyId :: binary(), - Jti :: binary(), - SubjectId :: binary(), - ClientType :: binary() -) -> {binary(), calendar:datetime()}. -generate_admin_refresh_token(SessionId, FamilyId, Jti, SubjectId, ClientType) -> - generate_refresh_jwt( - get_admin_jwk(), SubjectId, <<"admin">>, SessionId, FamilyId, Jti, ClientType - ). - --spec verify_admin_refresh_token(Token :: binary()) -> - {ok, map()} | {error, atom()}. -verify_admin_refresh_token(Token) -> - verify_refresh_jwt(get_admin_jwk(), Token, <<"admin">>). - -generate_refresh_jwt(JWK, SubjectId, Audience, SessionId, FamilyId, Jti, ClientType) -> - ExpTime = erlang:system_time(second) + ?REFRESH_TTL_SECONDS, - Claims = #{ - <<"typ">> => <<"refresh">>, - <<"sub">> => SubjectId, - <<"aud">> => Audience, - <<"sid">> => SessionId, - <<"fid">> => FamilyId, - <<"jti">> => Jti, - <<"client">> => ClientType, - <<"exp">> => ExpTime, - <<"iat">> => erlang:system_time(second) - }, - JWT = jose_jwt:sign(JWK, #{<<"alg">> => <<"HS256">>}, Claims), - {_, Token} = jose_jws:compact(JWT), - ExpiresAt = calendar:gregorian_seconds_to_datetime( - calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + ?REFRESH_TTL_SECONDS - ), - {Token, ExpiresAt}. - -verify_refresh_jwt(JWK, Token, ExpectedAud) -> - try - case jose_jwt:verify(JWK, Token) of - {true, {jose_jwt, Claims}, _} -> - validate_refresh_claims(Claims, ExpectedAud); - {true, Claims, _} when is_map(Claims) -> - validate_refresh_claims(Claims, ExpectedAud); - _ -> - {error, invalid_signature} - end - catch - _:_ -> {error, invalid_token} - end. - -validate_refresh_claims(Claims, ExpectedAud) -> - case maps:get(<<"typ">>, Claims, undefined) of - <<"refresh">> -> - case maps:get(<<"aud">>, Claims, undefined) of - ExpectedAud -> - validate_refresh_exp(Claims); - _ -> - {error, invalid_audience} - end; - _ -> - {error, invalid_token_type} - end. - -validate_refresh_exp(Claims) -> - Required = [<<"sid">>, <<"fid">>, <<"jti">>, <<"sub">>, <<"exp">>], - case [K || K <- Required, not maps:is_key(K, Claims)] of - [] -> - case maps:get(<<"exp">>, Claims) of - Exp when is_integer(Exp) -> - case Exp > erlang:system_time(second) of - true -> {ok, Claims}; - false -> {error, expired} - end; - _ -> - {error, expired} - end; - _ -> - {error, invalid_token} +-module(eventhub_auth). +-export([ + generate_user_token/2, + generate_admin_token/2, + verify_user_token/1, + verify_admin_token/1, + authenticate_user_request/3, + authenticate_admin_request/3, + generate_refresh_token/1, + generate_admin_refresh_token/5, + verify_admin_refresh_token/1, + generate_user_refresh_token/5, + verify_user_refresh_token/1 +]). + +%% ========== КОНФИГУРАЦИЯ СЕКРЕТОВ ========== + +-spec get_user_secret() -> binary(). +get_user_secret() -> + case application:get_env(eventhub, jwt_secret) of + {ok, Secret} when is_binary(Secret) -> Secret; + undefined -> get_user_secret_from_env() + end. + +get_user_secret_from_env() -> + case os:getenv("JWT_SECRET") of + false -> <<"user-secret-key-32-bytes-minimum!">>; + S -> list_to_binary(S) + end. + +-spec get_admin_secret() -> binary(). +get_admin_secret() -> + case application:get_env(eventhub, admin_jwt_secret) of + {ok, Secret} when is_binary(Secret) -> Secret; + undefined -> get_admin_secret_from_env() + end. + +get_admin_secret_from_env() -> + case os:getenv("ADMIN_JWT_SECRET") of + false -> <<"admin-secret-key-32-bytes-minimum!">>; + S -> list_to_binary(S) + end. + +-spec get_user_jwk() -> jose_jwk:key(). +get_user_jwk() -> jose_jwk:from_oct(get_user_secret()). + +-spec get_admin_jwk() -> jose_jwk:key(). +get_admin_jwk() -> jose_jwk:from_oct(get_admin_secret()). + +%% ========== ГЕНЕРАЦИЯ ТОКЕНОВ ========== + +-spec generate_user_token(UserId :: binary(), Role :: binary()) -> binary(). +generate_user_token(UserId, Role) -> + generate_token(get_user_jwk(), UserId, Role, <<"user">>). + +-spec generate_admin_token(UserId :: binary(), Role :: binary()) -> binary(). +generate_admin_token(UserId, Role) -> + generate_token(get_admin_jwk(), UserId, Role, <<"admin">>). + +generate_token(JWK, UserId, Role, Audience) -> + ExpTime = erlang:system_time(second) + 86400, + Claims = #{ + <<"user_id">> => UserId, + <<"role">> => Role, + <<"aud">> => Audience, + <<"exp">> => ExpTime, + <<"iat">> => erlang:system_time(second) + }, + JWT = jose_jwt:sign(JWK, #{<<"alg">> => <<"HS256">>}, Claims), + {_, Token} = jose_jws:compact(JWT), + Token. + +%% ========== ПРОВЕРКА ТОКЕНОВ ========== + +-spec verify_user_token(Token :: binary()) -> + {ok, UserId :: binary(), Role :: binary()} | {error, atom()}. +verify_user_token(Token) -> + verify_token(get_user_jwk(), Token, <<"user">>). + +-spec verify_admin_token(Token :: binary()) -> + {ok, UserId :: binary(), Role :: binary()} | {error, atom()}. +verify_admin_token(Token) -> + verify_token(get_admin_jwk(), Token, <<"admin">>). + +verify_token(JWK, Token, ExpectedAud) -> + try + case jose_jwt:verify(JWK, Token) of + {true, {jose_jwt, Claims}, _} -> + validate_claims(Claims, ExpectedAud); + {true, Claims, _} when is_map(Claims) -> + validate_claims(Claims, ExpectedAud); + _ -> + {error, invalid_signature} + end + catch + _:_ -> {error, invalid_token} + end. + +validate_claims(Claims, ExpectedAud) -> + case maps:find(<<"aud">>, Claims) of + {ok, ExpectedAud} -> + case maps:find(<<"exp">>, Claims) of + {ok, Exp} when is_integer(Exp) -> + Now = erlang:system_time(second), + if + Exp > Now -> + UserId = maps:get(<<"user_id">>, Claims, undefined), + Role = maps:get(<<"role">>, Claims, <<"user">>), + {ok, UserId, Role}; + true -> + {error, expired} + end; + {ok, _Exp} -> {error, expired}; + _ -> {error, no_expiration} + end; + {ok, _} -> {error, invalid_audience}; + error -> {error, missing_audience} + end. + +%% ========== АУТЕНТИФИКАЦИЯ ЗАПРОСА ========== + +-spec authenticate_user_request(Req :: cowboy_req:req(), Email :: binary(), Password :: binary()) -> + {ok, Token :: binary(), User :: map()} | {error, atom()}. +authenticate_user_request(_Req, Email, Password) -> + case logic_auth:authenticate_user(Email, Password) of + {ok, User} -> + UserId = maps:get(id, User), + Role = maps:get(role, User, <<"user">>), + Token = generate_user_token(UserId, Role), + {ok, Token, User}; + Error -> Error + end. + +-spec authenticate_admin_request(Req :: cowboy_req:req(), Email :: binary(), Password :: binary()) -> + {ok, Token :: binary(), User :: map()} | {error, atom()}. +authenticate_admin_request(_Req, Email, Password) -> + case logic_auth:authenticate_admin(Email, Password) of + {ok, AdminMap} -> + Role = maps:get(role, AdminMap, <<"admin">>), + case is_admin_role(Role) of + true -> + AdminId = maps:get(id, AdminMap), + Token = generate_admin_token(AdminId, Role), + {ok, Token, AdminMap}; + false -> {error, insufficient_permissions} + end; + Error -> Error + end. + +is_admin_role(Role) -> + lists:member(Role, [<<"admin">>, <<"superadmin">>, <<"moderator">>, <<"support">>]). + +-define(REFRESH_TTL_SECONDS, 30 * 24 * 3600). + +%% ========== REFRESH TOKEN (opaque, legacy — не используется login/refresh user с фазы 2) ========== + +-spec generate_refresh_token(UserId :: binary()) -> {binary(), calendar:datetime()}. +generate_refresh_token(_UserId) -> + RefreshToken = base64:encode(crypto:strong_rand_bytes(32)), + Now = calendar:universal_time(), + ExpiresAt = calendar:gregorian_seconds_to_datetime( + calendar:datetime_to_gregorian_seconds(Now) + ?REFRESH_TTL_SECONDS + ), + {RefreshToken, ExpiresAt}. + +%% ========== REFRESH JWT (единая session-модель) ========== + +-spec generate_admin_refresh_token( + SessionId :: binary(), + FamilyId :: binary(), + Jti :: binary(), + SubjectId :: binary(), + ClientType :: binary() +) -> {binary(), calendar:datetime()}. +generate_admin_refresh_token(SessionId, FamilyId, Jti, SubjectId, ClientType) -> + generate_refresh_jwt( + get_admin_jwk(), SubjectId, <<"admin">>, SessionId, FamilyId, Jti, ClientType + ). + +-spec verify_admin_refresh_token(Token :: binary()) -> + {ok, map()} | {error, atom()}. +verify_admin_refresh_token(Token) -> + verify_refresh_jwt(get_admin_jwk(), Token, <<"admin">>). + +-spec generate_user_refresh_token( + SessionId :: binary(), + FamilyId :: binary(), + Jti :: binary(), + SubjectId :: binary(), + ClientType :: binary() +) -> {binary(), calendar:datetime()}. +generate_user_refresh_token(SessionId, FamilyId, Jti, SubjectId, ClientType) -> + generate_refresh_jwt( + get_user_jwk(), SubjectId, <<"user">>, SessionId, FamilyId, Jti, ClientType + ). + +-spec verify_user_refresh_token(Token :: binary()) -> + {ok, map()} | {error, atom()}. +verify_user_refresh_token(Token) -> + verify_refresh_jwt(get_user_jwk(), Token, <<"user">>). + +generate_refresh_jwt(JWK, SubjectId, Audience, SessionId, FamilyId, Jti, ClientType) -> + ExpTime = erlang:system_time(second) + ?REFRESH_TTL_SECONDS, + Claims = #{ + <<"typ">> => <<"refresh">>, + <<"sub">> => SubjectId, + <<"aud">> => Audience, + <<"sid">> => SessionId, + <<"fid">> => FamilyId, + <<"jti">> => Jti, + <<"client">> => ClientType, + <<"exp">> => ExpTime, + <<"iat">> => erlang:system_time(second) + }, + JWT = jose_jwt:sign(JWK, #{<<"alg">> => <<"HS256">>}, Claims), + {_, Token} = jose_jws:compact(JWT), + ExpiresAt = calendar:gregorian_seconds_to_datetime( + calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + ?REFRESH_TTL_SECONDS + ), + {Token, ExpiresAt}. + +verify_refresh_jwt(JWK, Token, ExpectedAud) -> + try + case jose_jwt:verify(JWK, Token) of + {true, {jose_jwt, Claims}, _} -> + validate_refresh_claims(Claims, ExpectedAud); + {true, Claims, _} when is_map(Claims) -> + validate_refresh_claims(Claims, ExpectedAud); + _ -> + {error, invalid_signature} + end + catch + _:_ -> {error, invalid_token} + end. + +validate_refresh_claims(Claims, ExpectedAud) -> + case maps:get(<<"typ">>, Claims, undefined) of + <<"refresh">> -> + case maps:get(<<"aud">>, Claims, undefined) of + ExpectedAud -> + validate_refresh_exp(Claims); + _ -> + {error, invalid_audience} + end; + _ -> + {error, invalid_token_type} + end. + +validate_refresh_exp(Claims) -> + Required = [<<"sid">>, <<"fid">>, <<"jti">>, <<"sub">>, <<"exp">>], + case [K || K <- Required, not maps:is_key(K, Claims)] of + [] -> + case maps:get(<<"exp">>, Claims) of + Exp when is_integer(Exp) -> + case Exp > erlang:system_time(second) of + true -> {ok, Claims}; + false -> {error, expired} + end; + _ -> + {error, expired} + end; + _ -> + {error, invalid_token} end. \ No newline at end of file diff --git a/src/logic/logic_auth_session.erl b/src/logic/logic_auth_session.erl index c03c717..0f875d0 100644 --- a/src/logic/logic_auth_session.erl +++ b/src/logic/logic_auth_session.erl @@ -1,62 +1,95 @@ -%%%------------------------------------------------------------------- -%%% @doc Бизнес-логика единой session-модели (refresh JWT + rotation). -%%% Фаза 1: admin API. Фазы 2–3: user / client web / mobile. -%%% @end -%%%------------------------------------------------------------------- --module(logic_auth_session). --include("records.hrl"). --export([issue_admin_tokens/2, refresh_admin/1]). - -%%%------------------------------------------------------------------- -%%% @doc Выдать пару access + refresh JWT при входе администратора. -%%% @end -%%%------------------------------------------------------------------- --spec issue_admin_tokens(AdminId :: binary(), Role :: binary()) -> - {ok, AccessToken :: binary(), RefreshToken :: binary()}. -issue_admin_tokens(AdminId, Role) -> - {ok, Session} = core_auth_session:create(AdminId, admin, <<"admin">>), - AccessToken = eventhub_auth:generate_admin_token(AdminId, Role), - {RefreshToken, _} = eventhub_auth:generate_admin_refresh_token( - Session#auth_session.session_id, - Session#auth_session.family_id, - Session#auth_session.current_jti, - AdminId, - <<"admin">> - ), - {ok, AccessToken, RefreshToken}. - -%%%------------------------------------------------------------------- -%%% @doc Обновить пару токенов администратора по refresh JWT. -%%% @end -%%%------------------------------------------------------------------- --spec refresh_admin(RefreshToken :: binary()) -> - {ok, AccessToken :: binary(), NewRefreshToken :: binary()} | - {error, atom()}. -refresh_admin(RefreshToken) -> - case eventhub_auth:verify_admin_refresh_token(RefreshToken) of - {ok, Claims} -> - do_refresh_admin(Claims); - Error -> - Error - end. - -do_refresh_admin(Claims) -> - SessionId = maps:get(<<"sid">>, Claims), - Jti = maps:get(<<"jti">>, Claims), - AdminId = maps:get(<<"sub">>, Claims), - FamilyId = maps:get(<<"fid">>, Claims), - case core_auth_session:rotate(SessionId, Jti) of - {ok, NewJti, _Session} -> - {ok, Admin} = core_admin:get_by_id(AdminId), - Role = atom_to_binary(Admin#admin.role, utf8), - AccessToken = eventhub_auth:generate_admin_token(AdminId, Role), - {NewRefresh, _} = eventhub_auth:generate_admin_refresh_token( - SessionId, FamilyId, NewJti, AdminId, <<"admin">> - ), - {ok, AccessToken, NewRefresh}; - {error, reuse_detected} -> - core_auth_session:revoke_family(FamilyId), - {error, reuse_detected}; - Error -> - Error - end. \ No newline at end of file +%%%------------------------------------------------------------------- +%%% @doc Бизнес-логика единой session-модели (refresh JWT + rotation). +%%% Фаза 1: admin API. Фаза 2: user API. Фаза 3: client web + mobile. +%%% @end +%%%------------------------------------------------------------------- +-module(logic_auth_session). +-include("records.hrl"). +-export([issue_admin_tokens/2, refresh_admin/1, + issue_user_tokens/2, refresh_user/1]). + +-define(USER_CLIENT_WEB, <<"web">>). + +%%%------------------------------------------------------------------- +%%% Admin (фаза 1) +%%%------------------------------------------------------------------- + +-spec issue_admin_tokens(AdminId :: binary(), Role :: binary()) -> + {ok, AccessToken :: binary(), RefreshToken :: binary()}. +issue_admin_tokens(AdminId, Role) -> + issue_tokens(AdminId, admin, <<"admin">>, Role, fun eventhub_auth:generate_admin_token/2, + fun eventhub_auth:generate_admin_refresh_token/5). + +-spec refresh_admin(RefreshToken :: binary()) -> + {ok, AccessToken :: binary(), NewRefreshToken :: binary()} | + {error, atom()}. +refresh_admin(RefreshToken) -> + refresh_subject(RefreshToken, fun eventhub_auth:verify_admin_refresh_token/1, + fun core_admin:get_by_id/1, admin, fun eventhub_auth:generate_admin_token/2, + fun eventhub_auth:generate_admin_refresh_token/5, <<"admin">>). + +%%%------------------------------------------------------------------- +%%% User (фаза 2) +%%%------------------------------------------------------------------- + +-spec issue_user_tokens(UserId :: binary(), Role :: binary()) -> + {ok, AccessToken :: binary(), RefreshToken :: binary()}. +issue_user_tokens(UserId, Role) -> + issue_tokens(UserId, user, ?USER_CLIENT_WEB, Role, fun eventhub_auth:generate_user_token/2, + fun eventhub_auth:generate_user_refresh_token/5). + +-spec refresh_user(RefreshToken :: binary()) -> + {ok, AccessToken :: binary(), NewRefreshToken :: binary()} | + {error, atom()}. +refresh_user(RefreshToken) -> + refresh_subject(RefreshToken, fun eventhub_auth:verify_user_refresh_token/1, + fun core_user:get_by_id/1, user, fun eventhub_auth:generate_user_token/2, + fun eventhub_auth:generate_user_refresh_token/5, ?USER_CLIENT_WEB). + +%%%=================================================================== +%%% Internal +%%%=================================================================== + +issue_tokens(SubjectId, SubjectType, ClientType, Role, GenAccess, GenRefresh) -> + {ok, Session} = core_auth_session:create(SubjectId, SubjectType, ClientType), + AccessToken = GenAccess(SubjectId, Role), + {RefreshToken, _} = GenRefresh( + Session#auth_session.session_id, + Session#auth_session.family_id, + Session#auth_session.current_jti, + SubjectId, + ClientType + ), + {ok, AccessToken, RefreshToken}. + +refresh_subject(RefreshToken, VerifyFn, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType) -> + case VerifyFn(RefreshToken) of + {ok, Claims} -> + do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType); + Error -> + Error + end. + +do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType) -> + SessionId = maps:get(<<"sid">>, Claims), + Jti = maps:get(<<"jti">>, Claims), + SubjectId = maps:get(<<"sub">>, Claims), + FamilyId = maps:get(<<"fid">>, Claims), + case core_auth_session:rotate(SessionId, Jti) of + {ok, NewJti, _Session} -> + {ok, Subject} = GetSubjectFn(SubjectId), + Role = subject_role(Subject, SubjectType), + AccessToken = GenAccess(SubjectId, Role), + {NewRefresh, _} = GenRefresh(SessionId, FamilyId, NewJti, SubjectId, ClientType), + {ok, AccessToken, NewRefresh}; + {error, reuse_detected} -> + core_auth_session:revoke_family(FamilyId), + {error, reuse_detected}; + Error -> + Error + end. + +subject_role(#admin{role = Role}, admin) -> + atom_to_binary(Role, utf8); +subject_role(#user{role = Role}, user) -> + atom_to_binary(Role, utf8). diff --git a/test/unit/auth_test.erl b/test/unit/auth_test.erl index 36a5e8e..60542c6 100644 --- a/test/unit/auth_test.erl +++ b/test/unit/auth_test.erl @@ -1,167 +1,191 @@ --module(auth_test). --include_lib("eunit/include/eunit.hrl"). - --define(JWT_SECRET, <<"test-user-secret-key-32-byt!">>). --define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>). - -%% ------------------------------------------------------------------ -%% EUnit фикстуры – запуск и остановка моков -%% ------------------------------------------------------------------ -setup() -> - ok = meck:new(logic_auth, [non_strict]), - application:set_env(eventhub, jwt_secret, ?JWT_SECRET), - application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET), - {ok, _} = application:ensure_all_started(jose), - ok. - -cleanup(_) -> - application:unset_env(eventhub, jwt_secret), - application:unset_env(eventhub, admin_jwt_secret), - application:stop(jose), - meck:unload(logic_auth). - -%% ------------------------------------------------------------------ -%% Тесты генерации токенов -%% ------------------------------------------------------------------ -generate_user_token_test_() -> - {setup, fun setup/0, fun cleanup/1, [ - {"Generate user token returns a binary", - fun() -> - Token = eventhub_auth:generate_user_token(<<"user123">>, <<"user">>), - ?assert(is_binary(Token)), - ?assert(size(Token) > 0) - end}, - {"Generated user token can be verified", - fun() -> - Token = eventhub_auth:generate_user_token(<<"user123">>, <<"user">>), - {ok, UserId, Role} = eventhub_auth:verify_user_token(Token), - ?assertEqual(<<"user123">>, UserId), - ?assertEqual(<<"user">>, Role) - end}, - {"Generate admin token with superadmin role", - fun() -> - Token = eventhub_auth:generate_admin_token(<<"admin1">>, <<"superadmin">>), - {ok, UserId, Role} = eventhub_auth:verify_admin_token(Token), - ?assertEqual(<<"admin1">>, UserId), - ?assertEqual(<<"superadmin">>, Role) - end} - ]}. - -%% ------------------------------------------------------------------ -%% Тесты верификации токенов (граничные случаи) -%% ------------------------------------------------------------------ -verify_token_errors_test_() -> - {setup, fun setup/0, fun cleanup/1, [ - {"Invalid token signature returns error", - fun() -> - FakeToken = <<"not.a.valid.token">>, - ?assertEqual({error, invalid_token}, eventhub_auth:verify_user_token(FakeToken)), - ?assertEqual({error, invalid_token}, eventhub_auth:verify_admin_token(FakeToken)) - end}, - {"User token rejected by admin verifier (different secret)", - fun() -> - Token = eventhub_auth:generate_user_token(<<"x">>, <<"user">>), - % Разные секреты → подпись недействительна для admin JWK - ?assertEqual({error, invalid_signature}, eventhub_auth:verify_admin_token(Token)) - end}, - {"Admin token rejected by user verifier (different secret)", - fun() -> - Token = eventhub_auth:generate_admin_token(<<"x">>, <<"admin">>), - ?assertEqual({error, invalid_signature}, eventhub_auth:verify_user_token(Token)) - end} - ]}. - -%% ------------------------------------------------------------------ -%% Тесты для authenticate_user_request/3 -%% ------------------------------------------------------------------ -authenticate_user_request_test_() -> - {setup, fun setup/0, fun cleanup/1, [ - {"Successful user login returns token and user data", - fun() -> - UserMap = #{id => <<"user1">>, email => <<"u@test.com">>, role => <<"user">>}, - ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, UserMap} end), - Req = undefined, - {ok, Token, ReturnedUser} = eventhub_auth:authenticate_user_request(Req, <<"u@test.com">>, <<"pass">>), - ?assert(is_binary(Token)), - ?assertEqual(UserMap, ReturnedUser), - {ok, UserId, Role} = eventhub_auth:verify_user_token(Token), - ?assertEqual(<<"user1">>, UserId), - ?assertEqual(<<"user">>, Role) - end}, - {"User login failure propagates error from logic_auth", - fun() -> - ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {error, bad_credentials} end), - Req = undefined, - ?assertEqual({error, bad_credentials}, eventhub_auth:authenticate_user_request(Req, <<"bad">>, <<"pwd">>)) - end} - ]}. - -%% ------------------------------------------------------------------ -%% Тесты для authenticate_admin_request/3 -%% ------------------------------------------------------------------ -authenticate_admin_request_test_() -> - {setup, fun setup/0, fun cleanup/1, [ - {"Successful admin login returns admin token", - fun() -> - AdminMap = #{id => <<"adm1">>, email => <<"admin@test.com">>, role => <<"superadmin">>}, - ok = meck:expect(logic_auth, authenticate_admin, fun(_Email, _Password) -> {ok, AdminMap} end), - Req = undefined, - {ok, Token, ReturnedUser} = eventhub_auth:authenticate_admin_request(Req, <<"admin@test.com">>, <<"pass">>), - ?assert(is_binary(Token)), - ?assertEqual(AdminMap, ReturnedUser), - {ok, UserId, Role} = eventhub_auth:verify_admin_token(Token), - ?assertEqual(<<"adm1">>, UserId), - ?assertEqual(<<"superadmin">>, Role) - end}, - {"Non-admin role is rejected with insufficient_permissions", - fun() -> - UserMap = #{id => <<"simpleuser">>, email => <<"u@test.com">>, role => <<"user">>}, - ok = meck:expect(logic_auth, authenticate_admin, fun(_Email, _Password) -> {ok, UserMap} end), - Req = undefined, - ?assertEqual({error, insufficient_permissions}, - eventhub_auth:authenticate_admin_request(Req, <<"u@test.com">>, <<"pwd">>)) - end}, - {"Moderator role is accepted as admin", - fun() -> - ModMap = #{id => <<"moder1">>, email => <<"mod@test.com">>, role => <<"moderator">>}, - ok = meck:expect(logic_auth, authenticate_admin, fun(_Email, _Password) -> {ok, ModMap} end), - Req = undefined, - {ok, Token, _} = eventhub_auth:authenticate_admin_request(Req, <<"mod@test.com">>, <<"pwd">>), - {ok, _, Role} = eventhub_auth:verify_admin_token(Token), - ?assertEqual(<<"moderator">>, Role) - end} - ]}. - -%% ------------------------------------------------------------------ -%% Тест generate_refresh_token/1 (legacy opaque, user flow) -%% ------------------------------------------------------------------ -generate_refresh_token_test() -> - {_, _} = eventhub_auth:generate_refresh_token(<<"anyuser">>). - -%% ------------------------------------------------------------------ -%% Тесты refresh JWT (admin) -%% ------------------------------------------------------------------ -admin_refresh_jwt_test_() -> - {setup, fun setup/0, fun cleanup/1, [ - {"Generate and verify admin refresh JWT", - fun() -> - Sid = <<"sess123">>, Fid = <<"fam456">>, Jti = <<"jti789">>, - {Token, _} = eventhub_auth:generate_admin_refresh_token( - Sid, Fid, Jti, <<"admin1">>, <<"admin">> - ), - ?assert(is_binary(Token)), - {ok, Claims} = eventhub_auth:verify_admin_refresh_token(Token), - ?assertEqual(Sid, maps:get(<<"sid">>, Claims)), - ?assertEqual(Fid, maps:get(<<"fid">>, Claims)), - ?assertEqual(Jti, maps:get(<<"jti">>, Claims)), - ?assertEqual(<<"admin1">>, maps:get(<<"sub">>, Claims)) - end}, - {"User refresh JWT rejected by admin verifier", - fun() -> - {Token, _} = eventhub_auth:generate_admin_refresh_token( - <<"s">>, <<"f">>, <<"j">>, <<"u">>, <<"admin">> - ), - ?assertMatch({error, invalid_signature}, - eventhub_auth:verify_user_token(Token)) - end} +-module(auth_test). +-include_lib("eunit/include/eunit.hrl"). + +-define(JWT_SECRET, <<"test-user-secret-key-32-byt!">>). +-define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>). + +%% ------------------------------------------------------------------ +%% EUnit фикстуры – запуск и остановка моков +%% ------------------------------------------------------------------ +setup() -> + ok = meck:new(logic_auth, [non_strict]), + application:set_env(eventhub, jwt_secret, ?JWT_SECRET), + application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET), + {ok, _} = application:ensure_all_started(jose), + ok. + +cleanup(_) -> + application:unset_env(eventhub, jwt_secret), + application:unset_env(eventhub, admin_jwt_secret), + application:stop(jose), + meck:unload(logic_auth). + +%% ------------------------------------------------------------------ +%% Тесты генерации токенов +%% ------------------------------------------------------------------ +generate_user_token_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"Generate user token returns a binary", + fun() -> + Token = eventhub_auth:generate_user_token(<<"user123">>, <<"user">>), + ?assert(is_binary(Token)), + ?assert(size(Token) > 0) + end}, + {"Generated user token can be verified", + fun() -> + Token = eventhub_auth:generate_user_token(<<"user123">>, <<"user">>), + {ok, UserId, Role} = eventhub_auth:verify_user_token(Token), + ?assertEqual(<<"user123">>, UserId), + ?assertEqual(<<"user">>, Role) + end}, + {"Generate admin token with superadmin role", + fun() -> + Token = eventhub_auth:generate_admin_token(<<"admin1">>, <<"superadmin">>), + {ok, UserId, Role} = eventhub_auth:verify_admin_token(Token), + ?assertEqual(<<"admin1">>, UserId), + ?assertEqual(<<"superadmin">>, Role) + end} + ]}. + +%% ------------------------------------------------------------------ +%% Тесты верификации токенов (граничные случаи) +%% ------------------------------------------------------------------ +verify_token_errors_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"Invalid token signature returns error", + fun() -> + FakeToken = <<"not.a.valid.token">>, + ?assertEqual({error, invalid_token}, eventhub_auth:verify_user_token(FakeToken)), + ?assertEqual({error, invalid_token}, eventhub_auth:verify_admin_token(FakeToken)) + end}, + {"User token rejected by admin verifier (different secret)", + fun() -> + Token = eventhub_auth:generate_user_token(<<"x">>, <<"user">>), + % Разные секреты → подпись недействительна для admin JWK + ?assertEqual({error, invalid_signature}, eventhub_auth:verify_admin_token(Token)) + end}, + {"Admin token rejected by user verifier (different secret)", + fun() -> + Token = eventhub_auth:generate_admin_token(<<"x">>, <<"admin">>), + ?assertEqual({error, invalid_signature}, eventhub_auth:verify_user_token(Token)) + end} + ]}. + +%% ------------------------------------------------------------------ +%% Тесты для authenticate_user_request/3 +%% ------------------------------------------------------------------ +authenticate_user_request_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"Successful user login returns token and user data", + fun() -> + UserMap = #{id => <<"user1">>, email => <<"u@test.com">>, role => <<"user">>}, + ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, UserMap} end), + Req = undefined, + {ok, Token, ReturnedUser} = eventhub_auth:authenticate_user_request(Req, <<"u@test.com">>, <<"pass">>), + ?assert(is_binary(Token)), + ?assertEqual(UserMap, ReturnedUser), + {ok, UserId, Role} = eventhub_auth:verify_user_token(Token), + ?assertEqual(<<"user1">>, UserId), + ?assertEqual(<<"user">>, Role) + end}, + {"User login failure propagates error from logic_auth", + fun() -> + ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {error, bad_credentials} end), + Req = undefined, + ?assertEqual({error, bad_credentials}, eventhub_auth:authenticate_user_request(Req, <<"bad">>, <<"pwd">>)) + end} + ]}. + +%% ------------------------------------------------------------------ +%% Тесты для authenticate_admin_request/3 +%% ------------------------------------------------------------------ +authenticate_admin_request_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"Successful admin login returns admin token", + fun() -> + AdminMap = #{id => <<"adm1">>, email => <<"admin@test.com">>, role => <<"superadmin">>}, + ok = meck:expect(logic_auth, authenticate_admin, fun(_Email, _Password) -> {ok, AdminMap} end), + Req = undefined, + {ok, Token, ReturnedUser} = eventhub_auth:authenticate_admin_request(Req, <<"admin@test.com">>, <<"pass">>), + ?assert(is_binary(Token)), + ?assertEqual(AdminMap, ReturnedUser), + {ok, UserId, Role} = eventhub_auth:verify_admin_token(Token), + ?assertEqual(<<"adm1">>, UserId), + ?assertEqual(<<"superadmin">>, Role) + end}, + {"Non-admin role is rejected with insufficient_permissions", + fun() -> + UserMap = #{id => <<"simpleuser">>, email => <<"u@test.com">>, role => <<"user">>}, + ok = meck:expect(logic_auth, authenticate_admin, fun(_Email, _Password) -> {ok, UserMap} end), + Req = undefined, + ?assertEqual({error, insufficient_permissions}, + eventhub_auth:authenticate_admin_request(Req, <<"u@test.com">>, <<"pwd">>)) + end}, + {"Moderator role is accepted as admin", + fun() -> + ModMap = #{id => <<"moder1">>, email => <<"mod@test.com">>, role => <<"moderator">>}, + ok = meck:expect(logic_auth, authenticate_admin, fun(_Email, _Password) -> {ok, ModMap} end), + Req = undefined, + {ok, Token, _} = eventhub_auth:authenticate_admin_request(Req, <<"mod@test.com">>, <<"pwd">>), + {ok, _, Role} = eventhub_auth:verify_admin_token(Token), + ?assertEqual(<<"moderator">>, Role) + end} + ]}. + +%% ------------------------------------------------------------------ +%% Тест generate_refresh_token/1 (legacy opaque, user flow) +%% ------------------------------------------------------------------ +generate_refresh_token_test() -> + {_, _} = eventhub_auth:generate_refresh_token(<<"anyuser">>). + +%% ------------------------------------------------------------------ +%% Тесты refresh JWT (admin) +%% ------------------------------------------------------------------ +admin_refresh_jwt_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"Generate and verify admin refresh JWT", + fun() -> + Sid = <<"sess123">>, Fid = <<"fam456">>, Jti = <<"jti789">>, + {Token, _} = eventhub_auth:generate_admin_refresh_token( + Sid, Fid, Jti, <<"admin1">>, <<"admin">> + ), + ?assert(is_binary(Token)), + {ok, Claims} = eventhub_auth:verify_admin_refresh_token(Token), + ?assertEqual(Sid, maps:get(<<"sid">>, Claims)), + ?assertEqual(Fid, maps:get(<<"fid">>, Claims)), + ?assertEqual(Jti, maps:get(<<"jti">>, Claims)), + ?assertEqual(<<"admin1">>, maps:get(<<"sub">>, Claims)) + end}, + {"Admin refresh JWT rejected by user verifier", + fun() -> + {Token, _} = eventhub_auth:generate_admin_refresh_token( + <<"s">>, <<"f">>, <<"j">>, <<"a">>, <<"admin">> + ), + ?assertMatch({error, invalid_signature}, + eventhub_auth:verify_user_refresh_token(Token)) + end} + ]}. + +user_refresh_jwt_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"Generate and verify user refresh JWT", + fun() -> + Sid = <<"sess123">>, Fid = <<"fam456">>, Jti = <<"jti789">>, + {Token, _} = eventhub_auth:generate_user_refresh_token( + Sid, Fid, Jti, <<"user1">>, <<"web">> + ), + ?assert(is_binary(Token)), + {ok, Claims} = eventhub_auth:verify_user_refresh_token(Token), + ?assertEqual(Sid, maps:get(<<"sid">>, Claims)), + ?assertEqual(<<"user">>, maps:get(<<"aud">>, Claims)), + ?assertEqual(<<"web">>, maps:get(<<"client">>, Claims)) + end}, + {"User refresh JWT rejected by admin verifier", + fun() -> + {Token, _} = eventhub_auth:generate_user_refresh_token( + <<"s">>, <<"f">>, <<"j">>, <<"u">>, <<"web">> + ), + ?assertMatch({error, invalid_signature}, + eventhub_auth:verify_admin_refresh_token(Token)) + end} ]}. \ No newline at end of file diff --git a/test/unit/logic_auth_session_tests.erl b/test/unit/logic_auth_session_tests.erl index 0c35c2f..779d91f 100644 --- a/test/unit/logic_auth_session_tests.erl +++ b/test/unit/logic_auth_session_tests.erl @@ -1,69 +1,114 @@ --module(logic_auth_session_tests). --include_lib("eunit/include/eunit.hrl"). --include("records.hrl"). - --define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>). - -setup() -> - ok = meck:new(core_admin, [non_strict]), - mnesia:start(), - catch ets:new(eventhub_counters, [named_table, public, set, {write_concurrency, true}]), - mnesia:create_table(auth_session, [ - {attributes, record_info(fields, auth_session)}, - {ram_copies, [node()]} - ]), - mnesia:add_table_index(auth_session, #auth_session.family_id), - mnesia:add_table_index(auth_session, #auth_session.subject_id), - application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET), - {ok, _} = application:ensure_all_started(jose), - ok. - -cleanup(_) -> - application:unset_env(eventhub, admin_jwt_secret), - application:stop(jose), - meck:unload(core_admin), - catch mnesia:delete_table(auth_session), - catch ets:delete(eventhub_counters), - mnesia:stop(), - ok. - -logic_auth_session_test_() -> - {foreach, fun setup/0, fun cleanup/1, [ - {"Issue and refresh admin tokens", fun test_issue_and_refresh/0}, - {"Refresh reuse revokes family", fun test_reuse_revokes/0} - ]}. - -test_issue_and_refresh() -> - AdminId = <<"adm1">>, - Admin = #admin{ - id = AdminId, email = <<"a@test.com">>, role = superadmin, - password_hash = <<>>, status = active, nickname = <<>>, - avatar_url = default, timezone = <<"UTC">>, language = <<"ru">>, - phone = <<>>, preferences = #{}, last_login = undefined, - created_at = undefined, updated_at = undefined - }, - ok = meck:expect(core_admin, get_by_id, fun(Id) -> - ?assertEqual(AdminId, Id), {ok, Admin} - end), - {ok, Access1, Refresh1} = logic_auth_session:issue_admin_tokens(AdminId, <<"superadmin">>), - ?assert(is_binary(Access1)), - {ok, _, _} = eventhub_auth:verify_admin_token(Access1), - {ok, Access2, Refresh2} = logic_auth_session:refresh_admin(Refresh1), - ?assert(is_binary(Access2)), - ?assertNotEqual(Refresh1, Refresh2), - {ok, _, _} = eventhub_auth:verify_admin_token(Access2). - -test_reuse_revokes() -> - AdminId = <<"adm1">>, - Admin = #admin{ - id = AdminId, email = <<"a@test.com">>, role = admin, - password_hash = <<>>, status = active, nickname = <<>>, - avatar_url = default, timezone = <<"UTC">>, language = <<"ru">>, - phone = <<>>, preferences = #{}, last_login = undefined, - created_at = undefined, updated_at = undefined - }, - ok = meck:expect(core_admin, get_by_id, fun(_) -> {ok, Admin} end), - {ok, _, Refresh1} = logic_auth_session:issue_admin_tokens(AdminId, <<"admin">>), - {ok, _, Refresh2} = logic_auth_session:refresh_admin(Refresh1), - ?assertMatch({error, reuse_detected}, logic_auth_session:refresh_admin(Refresh1)), - ?assertMatch({error, revoked}, logic_auth_session:refresh_admin(Refresh2)). +-module(logic_auth_session_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>). +-define(USER_JWT_SECRET, <<"test-user-secret-key-32-by">>). + +setup() -> + ok = meck:new(core_admin, [non_strict]), + ok = meck:new(core_user, [non_strict]), + mnesia:start(), + catch ets:new(eventhub_counters, [named_table, public, set, {write_concurrency, true}]), + mnesia:create_table(auth_session, [ + {attributes, record_info(fields, auth_session)}, + {ram_copies, [node()]} + ]), + mnesia:add_table_index(auth_session, #auth_session.family_id), + mnesia:add_table_index(auth_session, #auth_session.subject_id), + application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET), + application:set_env(eventhub, jwt_secret, ?USER_JWT_SECRET), + {ok, _} = application:ensure_all_started(jose), + ok. + +cleanup(_) -> + application:unset_env(eventhub, admin_jwt_secret), + application:unset_env(eventhub, jwt_secret), + application:stop(jose), + meck:unload(core_admin), + meck:unload(core_user), + catch mnesia:delete_table(auth_session), + catch ets:delete(eventhub_counters), + mnesia:stop(), + ok. + +logic_auth_session_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"Issue and refresh admin tokens", fun test_issue_and_refresh_admin/0}, + {"Refresh admin reuse revokes family", fun test_admin_reuse_revokes/0}, + {"Issue and refresh user tokens", fun test_issue_and_refresh_user/0}, + {"Refresh user reuse revokes family", fun test_user_reuse_revokes/0} + ]}. + +test_issue_and_refresh_admin() -> + AdminId = <<"adm1">>, + Admin = #admin{ + id = AdminId, email = <<"a@test.com">>, role = superadmin, + password_hash = <<>>, status = active, nickname = <<>>, + avatar_url = default, timezone = <<"UTC">>, language = <<"ru">>, + phone = <<>>, preferences = #{}, last_login = undefined, + created_at = undefined, updated_at = undefined + }, + ok = meck:expect(core_admin, get_by_id, fun(Id) -> + ?assertEqual(AdminId, Id), {ok, Admin} + end), + {ok, Access1, Refresh1} = logic_auth_session:issue_admin_tokens(AdminId, <<"superadmin">>), + ?assert(is_binary(Access1)), + {ok, _, _} = eventhub_auth:verify_admin_token(Access1), + {ok, Access2, Refresh2} = logic_auth_session:refresh_admin(Refresh1), + ?assert(is_binary(Access2)), + ?assertNotEqual(Refresh1, Refresh2), + {ok, _, _} = eventhub_auth:verify_admin_token(Access2). + +test_admin_reuse_revokes() -> + AdminId = <<"adm1">>, + Admin = #admin{ + id = AdminId, email = <<"a@test.com">>, role = admin, + password_hash = <<>>, status = active, nickname = <<>>, + avatar_url = default, timezone = <<"UTC">>, language = <<"ru">>, + phone = <<>>, preferences = #{}, last_login = undefined, + created_at = undefined, updated_at = undefined + }, + ok = meck:expect(core_admin, get_by_id, fun(_) -> {ok, Admin} end), + {ok, _, Refresh1} = logic_auth_session:issue_admin_tokens(AdminId, <<"admin">>), + {ok, _, Refresh2} = logic_auth_session:refresh_admin(Refresh1), + ?assertMatch({error, reuse_detected}, logic_auth_session:refresh_admin(Refresh1)), + ?assertMatch({error, revoked}, logic_auth_session:refresh_admin(Refresh2)). + +test_issue_and_refresh_user() -> + UserId = <<"usr1">>, + User = #user{ + id = UserId, email = <<"u@test.com">>, role = user, + password_hash = <<>>, status = active, nickname = <<>>, + avatar_url = default, timezone = <<"UTC">>, language = <<"ru">>, + phone = <<>>, preferences = #{}, last_login = undefined, + created_at = undefined, updated_at = undefined + }, + ok = meck:expect(core_user, get_by_id, fun(Id) -> + ?assertEqual(UserId, Id), {ok, User} + end), + {ok, Access1, Refresh1} = logic_auth_session:issue_user_tokens(UserId, <<"user">>), + ?assert(is_binary(Access1)), + {ok, _, _} = eventhub_auth:verify_user_token(Access1), + {ok, Claims} = eventhub_auth:verify_user_refresh_token(Refresh1), + ?assertEqual(<<"user">>, maps:get(<<"aud">>, Claims)), + ?assertEqual(<<"web">>, maps:get(<<"client">>, Claims)), + {ok, Access2, Refresh2} = logic_auth_session:refresh_user(Refresh1), + ?assert(is_binary(Access2)), + ?assertNotEqual(Refresh1, Refresh2), + {ok, _, _} = eventhub_auth:verify_user_token(Access2). + +test_user_reuse_revokes() -> + UserId = <<"usr1">>, + User = #user{ + id = UserId, email = <<"u@test.com">>, role = user, + password_hash = <<>>, status = active, nickname = <<>>, + avatar_url = default, timezone = <<"UTC">>, language = <<"ru">>, + phone = <<>>, preferences = #{}, last_login = undefined, + created_at = undefined, updated_at = undefined + }, + ok = meck:expect(core_user, get_by_id, fun(_) -> {ok, User} end), + {ok, _, Refresh1} = logic_auth_session:issue_user_tokens(UserId, <<"user">>), + {ok, _, Refresh2} = logic_auth_session:refresh_user(Refresh1), + ?assertMatch({error, reuse_detected}, logic_auth_session:refresh_user(Refresh1)), + ?assertMatch({error, revoked}, logic_auth_session:refresh_user(Refresh2)).