This commit is contained in:
2026-04-20 10:28:53 +03:00
parent 7e776ea6e3
commit 4224da1a22
11 changed files with 520 additions and 6 deletions

1
.gitignore vendored
View File

@@ -21,3 +21,4 @@ logs
rebar3.crashdump
*~
/.tool-versions
/rebar.lock

View File

@@ -1,4 +1,4 @@
{erl_opts, [debug_info]}.
{erl_opts, [debug_info, {i, "include"}]}.
{deps, [
{cowboy, "2.10.0"},
{jsx, "3.1.0"},

93
src/core/core_user.erl Normal file
View File

@@ -0,0 +1,93 @@
-module(core_user).
-include("records.hrl").
-export([create/2, get_by_id/1, get_by_email/1, update/2, delete/1]).
-export([email_exists/1]).
%% Создание пользователя
create(Email, Password) ->
% Сначала проверяем, существует ли email
case email_exists(Email) of
true ->
{error, email_exists};
false ->
Id = generate_id(),
{ok, PasswordHash} = logic_auth:hash_password(Password),
User = #user{
id = Id,
email = Email,
password_hash = PasswordHash,
role = user,
status = active,
created_at = calendar:universal_time(),
updated_at = calendar:universal_time()
},
F = fun() ->
mnesia:write(User),
{ok, User}
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end
end.
%% Получение пользователя по ID
get_by_id(Id) ->
case mnesia:dirty_read(user, Id) of
[] -> {error, not_found};
[User] -> {ok, User}
end.
%% Получение пользователя по email (через индекс позже)
get_by_email(Email) ->
Match = #user{email = Email, _ = '_'},
case mnesia:dirty_match_object(Match) of
[] -> {error, not_found};
[User] -> {ok, User}
end.
%% Проверка существования email
email_exists(Email) ->
case get_by_email(Email) of
{ok, _} -> true;
{error, _} -> false
end.
%% Обновление пользователя
update(Id, Updates) ->
F = fun() ->
case mnesia:read(user, Id) of
[] ->
{error, not_found};
[User] ->
UpdatedUser = apply_updates(User, Updates),
mnesia:write(UpdatedUser),
{ok, UpdatedUser}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
%% Удаление пользователя (soft delete)
delete(Id) ->
update(Id, [{status, deleted}]).
%% Внутренние функции
generate_id() ->
base64:encode(crypto:strong_rand_bytes(16)).
apply_updates(User, Updates) ->
lists:foldl(fun({Field, Value}, U) -> set_field(Field, Value, U) end, User, Updates).
set_field(email, Value, User) -> User#user{email = Value, updated_at = calendar:universal_time()};
set_field(password_hash, Value, User) -> User#user{password_hash = Value, updated_at = calendar:universal_time()};
set_field(role, Value, User) -> User#user{role = Value, updated_at = calendar:universal_time()};
set_field(status, Value, User) -> User#user{status = Value, updated_at = calendar:universal_time()};
set_field(_, _, User) -> User.

View File

@@ -1,4 +1,3 @@
%% Основной модуль приложения
-module(eventhub_app).
-behaviour(application).
@@ -7,6 +6,7 @@
start(_StartType, _StartArgs) ->
% Запускаем Mnesia
application:ensure_all_started(mnesia),
application:ensure_all_started(cowboy),
case infra_sup:start_link() of
{ok, Pid} ->
@@ -14,7 +14,9 @@ start(_StartType, _StartArgs) ->
ok = infra_mnesia:init_tables(),
ok = infra_mnesia:wait_for_tables(),
% Здесь позже запустим HTTP-сервер и другие сервисы
% Запускаем HTTP-сервер
start_http(),
{ok, Pid};
Error ->
Error
@@ -22,3 +24,33 @@ start(_StartType, _StartArgs) ->
stop(_State) ->
ok.
%% Internal functions
start_http() ->
Port = application:get_env(eventhub, http_port, 8080),
% Настройка маршрутов
Dispatch = cowboy_router:compile([
{'_', [
{"/health", handler_health, []},
{"/v1/register", handler_register, []},
{"/v1/login", handler_login, []},
{"/v1/refresh", handler_refresh, []},
{"/v1/user/me", handler_user_me, []}
]}
]),
% Настройка middleware
Middlewares = [
cowboy_router,
cowboy_handler
],
Env = #{dispatch => Dispatch},
cowboy:start_clear(http, [{port, Port}], #{
env => Env,
middlewares => Middlewares
}),
io:format("HTTP server started on port ~p~n", [Port]).

View File

@@ -1,6 +1,7 @@
-module(handler_health).
-export([init/2]).
init(Req, _Opts) ->
{ok, Resp} = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, <<"{\"status\":\"ok\"}">>, Req),
Resp.
Body = jsx:encode(#{status => <<"ok">>}),
cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Body, Req).

View File

@@ -0,0 +1,67 @@
-module(handler_login).
-include("records.hrl").
-export([init/2]).
init(Req, Opts) ->
handle(Req, Opts).
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> ->
{ok, Body, Req1} = cowboy_req:read_body(Req),
case jsx:decode(Body, [return_maps]) of
#{<<"email">> := Email, <<"password">> := Password} ->
case core_user:get_by_email(Email) of
{ok, User} ->
case logic_auth:verify_password(Password, User#user.password_hash) of
{ok, true} ->
case User#user.status of
active ->
Token = logic_auth:generate_jwt(User#user.id, User#user.role),
{RefreshToken, ExpiresAt} = logic_auth:generate_refresh_token(User#user.id),
save_refresh_token(User#user.id, RefreshToken, ExpiresAt),
Response = #{
user => #{
id => User#user.id,
email => User#user.email,
role => User#user.role
},
token => Token,
refresh_token => RefreshToken
},
send_json(Req1, 200, Response);
frozen ->
send_error(Req1, 403, <<"Account frozen">>);
deleted ->
send_error(Req1, 403, <<"Account deleted">>)
end;
_ ->
send_error(Req1, 401, <<"Invalid credentials">>)
end;
{error, not_found} ->
send_error(Req1, 401, <<"Invalid credentials">>)
end;
_ ->
send_error(Req1, 400, <<"Invalid request body">>)
end;
_ ->
send_error(Req, 405, <<"Method not allowed">>)
end.
save_refresh_token(UserId, Token, ExpiresAt) ->
Session = #session{
token = Token,
user_id = UserId,
expires_at = ExpiresAt,
type = refresh
},
mnesia:dirty_write(Session).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req).
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req).

View File

@@ -0,0 +1,91 @@
-module(handler_refresh).
-include("records.hrl").
-export([init/2]).
init(Req, Opts) ->
handle(Req, Opts).
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> ->
{ok, Body, Req1} = cowboy_req:read_body(Req),
case jsx:decode(Body, [return_maps]) of
#{<<"refresh_token">> := RefreshToken} ->
case validate_refresh_token(RefreshToken) of
{ok, UserId} ->
case core_user:get_by_id(UserId) of
{ok, User} ->
% Генерируем новые токены
NewToken = logic_auth:generate_jwt(User#user.id, User#user.role),
{NewRefreshToken, ExpiresAt} = logic_auth:generate_refresh_token(User#user.id),
% Сохраняем новый refresh token
save_refresh_token(User#user.id, NewRefreshToken, ExpiresAt),
% Удаляем старый refresh token
delete_refresh_token(RefreshToken),
Response = #{
token => NewToken,
refresh_token => NewRefreshToken
},
send_json(Req1, 200, Response);
{error, not_found} ->
send_error(Req1, 401, <<"User not found">>)
end;
{error, expired} ->
send_error(Req1, 401, <<"Refresh token expired">>);
{error, invalid} ->
send_error(Req1, 401, <<"Invalid refresh token">>)
end;
_ ->
send_error(Req1, 400, <<"Missing refresh_token">>)
end;
_ ->
send_error(Req, 405, <<"Method not allowed">>)
end.
validate_refresh_token(Token) ->
case get_session_by_token(Token) of
{ok, Session} ->
% Проверяем срок действия
Now = calendar:universal_time(),
case Session#session.expires_at > Now of
true -> {ok, Session#session.user_id};
false -> {error, expired}
end;
{error, not_found} ->
{error, invalid}
end.
get_session_by_token(Token) ->
Match = #session{token = Token, type = refresh, _ = '_'},
case mnesia:dirty_match_object(Match) of
[] -> {error, not_found};
[Session] -> {ok, Session}
end.
save_refresh_token(UserId, Token, ExpiresAt) ->
Session = #session{
token = Token,
user_id = UserId,
expires_at = ExpiresAt,
type = refresh
},
mnesia:dirty_write(Session).
delete_refresh_token(Token) ->
Match = #session{token = Token, type = refresh, _ = '_'},
case mnesia:dirty_match_object(Match) of
[] -> ok;
[Session] -> mnesia:dirty_delete_object(Session)
end.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req).
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req).

View File

@@ -0,0 +1,48 @@
-module(handler_register).
-include("records.hrl").
-export([init/2]).
init(Req, Opts) ->
handle(Req, Opts).
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> ->
{ok, Body, Req1} = cowboy_req:read_body(Req),
case jsx:decode(Body, [return_maps]) of
#{<<"email">> := Email, <<"password">> := Password} ->
case core_user:email_exists(Email) of
true ->
send_error(Req1, 409, <<"Email already exists">>);
false ->
case core_user:create(Email, Password) of
{ok, User} ->
Token = logic_auth:generate_jwt(User#user.id, User#user.role),
Response = #{
user => #{
id => User#user.id,
email => User#user.email,
role => User#user.role
},
token => Token
},
send_json(Req1, 201, Response);
{error, _} ->
send_error(Req1, 500, <<"Internal server error">>)
end
end;
_ ->
send_error(Req1, 400, <<"Invalid request body">>)
end;
_ ->
send_error(Req, 405, <<"Method not allowed">>)
end.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req).
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req).

View File

@@ -0,0 +1,57 @@
-module(handler_user_me).
-include("records.hrl").
-export([init/2]).
init(Req, Opts) ->
handle(Req, Opts).
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> ->
case authenticate(Req) of
{ok, UserId, Req1} ->
case core_user:get_by_id(UserId) of
{ok, User} ->
Response = #{
id => User#user.id,
email => User#user.email,
role => User#user.role,
status => User#user.status,
created_at => User#user.created_at,
updated_at => User#user.updated_at
},
send_json(Req1, 200, Response);
{error, not_found} ->
send_error(Req1, 404, <<"User not found">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end;
_ ->
send_error(Req, 405, <<"Method not allowed">>)
end.
authenticate(Req) ->
case cowboy_req:parse_header(<<"authorization">>, Req) of
{bearer, Token} ->
case logic_auth:verify_jwt(Token) of
{ok, Claims} ->
UserId = maps:get(<<"user_id">>, Claims),
{ok, UserId, Req};
{error, expired} ->
{error, 401, <<"Token expired">>, Req};
{error, _} ->
{error, 401, <<"Invalid token">>, Req}
end;
_ ->
{error, 401, <<"Missing or invalid Authorization header">>, Req}
end.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req).
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req).

88
src/logic/logic_auth.erl Normal file
View File

@@ -0,0 +1,88 @@
-module(logic_auth).
-export([hash_password/1, verify_password/2]).
-export([generate_jwt/2, verify_jwt/1, extract_claims/1]).
-export([generate_refresh_token/1]).
%% ============ Argon2 хеширование ============
hash_password(Password) when is_binary(Password) ->
argon2:hash(Password).
verify_password(Password, Hash) when is_binary(Password), is_binary(Hash) ->
argon2:verify(Password, Hash).
%% ============ JWT с использованием jose ============
get_jwt_secret() ->
<<"my-super-secret-key-for-jwt-32-bytes!">>.
get_jwk() ->
jose_jwk:from_oct(get_jwt_secret()).
generate_jwt(UserId, Role) ->
JWK = get_jwk(),
ExpTime = os:system_time(seconds) + 86400, % 24 часа
Claims = #{
<<"user_id">> => UserId,
<<"role">> => Role,
<<"exp">> => ExpTime,
<<"iat">> => os:system_time(seconds)
},
JWT = jose_jwt:sign(JWK, #{<<"alg">> => <<"HS256">>}, Claims),
{_, Token} = jose_jws:compact(JWT),
Token.
verify_jwt(Token) when is_binary(Token) ->
try
JWK = get_jwk(),
case jose_jwt:verify(JWK, Token) of
{true, {jose_jwt, Claims}, _} ->
case check_expiry(Claims) of
true -> {ok, Claims};
false -> {error, expired}
end;
{true, Claims, _} when is_map(Claims) ->
case check_expiry(Claims) of
true -> {ok, Claims};
false -> {error, expired}
end;
{false, _, _} ->
{error, invalid_signature}
end
catch
_:_ -> {error, invalid_token}
end.
extract_claims(Token) when is_binary(Token) ->
try
JWK = get_jwk(),
case jose_jwt:verify(JWK, Token) of
{true, {jose_jwt, Claims}, _} ->
{ok, Claims};
{true, Claims, _} when is_map(Claims) ->
{ok, Claims};
_ ->
{error, invalid_token}
end
catch
_:_ -> {error, invalid_token}
end.
check_expiry(Claims) ->
case maps:find(<<"exp">>, Claims) of
{ok, Exp} when is_integer(Exp) ->
Exp > os:system_time(seconds);
_ ->
false
end.
%% ============ Refresh Token ============
generate_refresh_token(_UserId) ->
Token = base64:encode(crypto:strong_rand_bytes(32)),
ExpiresAt = calendar:universal_time_to_local_time(
calendar:gregorian_seconds_to_datetime(
calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 30 * 86400
)
),
{Token, ExpiresAt}.

View File

@@ -0,0 +1,36 @@
-module(middleware_auth).
-behaviour(cowboy_middleware).
-export([execute/2]).
execute(Req, Env) ->
case authenticate(Req) of
{ok, UserId, Role} ->
% Добавляем user_id и role в env
NewReq = cowboy_req:set_resp_header(<<"X-User-Id">>, UserId, Req),
{ok, NewReq, [{user_id, UserId}, {role, Role} | Env]};
{error, {Code, Message}} ->
send_error(Req, Code, Message)
end.
authenticate(Req) ->
case cowboy_req:parse_header(<<"authorization">>, Req) of
{ok, <<"Bearer ", Token/binary>>, _} ->
case logic_auth:verify_jwt(Token) of
{ok, Claims} ->
UserId = maps:get(<<"user_id">>, Claims),
Role = maps:get(<<"role">>, Claims),
{ok, UserId, Role};
{error, expired} ->
{error, {401, <<"Token expired">>}};
{error, _} ->
{error, {401, <<"Invalid token">>}}
end;
_ ->
{error, {401, <<"Missing or invalid Authorization header">>}}
end.
send_error(Req, Code, Message) ->
Body = jsx:encode(#{error => Message}),
NewReq = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{stop, NewReq}.