Stage 2
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ logs
|
||||
rebar3.crashdump
|
||||
*~
|
||||
/.tool-versions
|
||||
/rebar.lock
|
||||
|
||||
@@ -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
93
src/core/core_user.erl
Normal 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.
|
||||
@@ -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,11 +14,43 @@ start(_StartType, _StartArgs) ->
|
||||
ok = infra_mnesia:init_tables(),
|
||||
ok = infra_mnesia:wait_for_tables(),
|
||||
|
||||
% Здесь позже запустим HTTP-сервер и другие сервисы
|
||||
% Запускаем HTTP-сервер
|
||||
start_http(),
|
||||
|
||||
{ok, Pid};
|
||||
Error ->
|
||||
Error
|
||||
end.
|
||||
|
||||
stop(_State) ->
|
||||
ok.
|
||||
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]).
|
||||
@@ -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).
|
||||
67
src/handlers/handler_login.erl
Normal file
67
src/handlers/handler_login.erl
Normal 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).
|
||||
91
src/handlers/handler_refresh.erl
Normal file
91
src/handlers/handler_refresh.erl
Normal 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).
|
||||
48
src/handlers/handler_register.erl
Normal file
48
src/handlers/handler_register.erl
Normal 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).
|
||||
57
src/handlers/handler_user_me.erl
Normal file
57
src/handlers/handler_user_me.erl
Normal 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
88
src/logic/logic_auth.erl
Normal 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}.
|
||||
36
src/middlewares/middleware_auth.erl
Normal file
36
src/middlewares/middleware_auth.erl
Normal 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}.
|
||||
Reference in New Issue
Block a user