Skip to content

Commit 26bc97b

Browse files
committed
feat: enhance authentication endpoints and request handling for improved user experience
- Updated ChangePasswordEndpoint, LoginEndpoint, LogoutEndpoint, MeEndpoint, PermissionsEndpoint, RefreshEndpoint, RegisterEndpoint, OAuthCallbackEndpoint, and OAuthProviderEndpoint to utilize structured request and response types, improving clarity and maintainability. - Implemented consistent error handling and response formatting across endpoints, ensuring uniformity in API responses. - Introduced new request structures for handling parameters in ChangePasswordRequest, LoginRequest, LogoutRequest, MeRequest, PermissionsRequest, RefreshTokenRequest, and RegisterRequest, enhancing modularity and organization. - Refactored response structures to include JSON serialization, improving the overall response handling in the authentication flow.
1 parent 986bda3 commit 26bc97b

24 files changed

Lines changed: 221 additions & 140 deletions

src/azu_cli/templates/auth/src/endpoints/auth/change_password_endpoint.cr.ecr

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require "../../models/user"
2+
require "../../response/auth/change_password_json"
23
<%- if using_jwt? || using_authly? %>
34
require "jwt"
45
<%- end %>
@@ -11,29 +12,43 @@ module <%= project.camelcase %>::Auth
1112

1213
def call : Auth::ChangePasswordResponse | Azu::Response::Empty
1314
user = current_user
14-
halt 401, {error: "Not authenticated"}.to_json unless user
15+
unless user
16+
context.response.status = HTTP::Status::UNAUTHORIZED
17+
context.response.content_type = "application/json"
18+
context.response.print({error: "Not authenticated"}.to_json)
19+
return Azu::Response::Empty.new
20+
end
1521

1622
req = change_password_request
1723
unless req.valid?
18-
halt 422, {errors: req.errors}.to_json
24+
context.response.status = HTTP::Status::UNPROCESSABLE_ENTITY
25+
context.response.content_type = "application/json"
26+
context.response.print({errors: req.errors}.to_json)
27+
return Azu::Response::Empty.new
1928
end
2029

2130
u = user.not_nil!
2231
unless u.verify_password(req.current_password)
23-
halt 401, {error: "Current password is incorrect"}.to_json
32+
context.response.status = HTTP::Status::UNAUTHORIZED
33+
context.response.content_type = "application/json"
34+
context.response.print({error: "Current password is incorrect"}.to_json)
35+
return Azu::Response::Empty.new
2436
end
2537

2638
u.password = req.new_password
2739
unless u.save
28-
halt 422, {errors: u.errors}.to_json
40+
context.response.status = HTTP::Status::UNPROCESSABLE_ENTITY
41+
context.response.content_type = "application/json"
42+
context.response.print({errors: u.errors}.to_json)
43+
return Azu::Response::Empty.new
2944
end
3045

3146
Auth::ChangePasswordResponse.new
3247
end
3348

3449
private def current_user : ::User?
3550
<%- if using_jwt? || using_authly? %>
36-
token = request.headers["Authorization"]?.try(&.sub("Bearer ", ""))
51+
token = context.request.headers["Authorization"]?.try(&.sub("Bearer ", ""))
3752
return nil unless token
3853

3954
payload, header = JWT.decode(token, jwt_secret, JWT::Algorithm::HS256)

src/azu_cli/templates/auth/src/endpoints/auth/login_endpoint.cr.ecr

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<%# Requires first (Crystal convention) %>
22
require "../../models/user"
3+
require "../../response/auth/login_json"
34
<%- if using_jwt? || using_authly? %>
45
require "jwt"
56
<%- end %>
@@ -13,12 +14,18 @@ module <%= project.camelcase %>::Auth
1314
def call : Auth::LoginResponse | Azu::Response::Empty
1415
req = login_request
1516
unless req.valid?
16-
halt 422, {errors: req.errors}.to_json
17+
context.response.status = HTTP::Status::UNPROCESSABLE_ENTITY
18+
context.response.content_type = "application/json"
19+
context.response.print({errors: req.errors}.to_json)
20+
return Azu::Response::Empty.new
1721
end
1822

1923
user = ::User.authenticate(req.email, req.password)
2024
unless user
21-
halt 401, {error: "Invalid email or password"}.to_json
25+
context.response.status = HTTP::Status::UNAUTHORIZED
26+
context.response.content_type = "application/json"
27+
context.response.print({error: "Invalid email or password"}.to_json)
28+
return Azu::Response::Empty.new
2229
end
2330

2431
<%- if using_jwt? || using_authly? %>

src/azu_cli/templates/auth/src/endpoints/auth/logout_endpoint.cr.ecr

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1+
require "../../requests/auth/logout_request"
2+
require "../../response/auth/logout_json"
3+
14
module <%= project.camelcase %>::Auth
25
struct LogoutEndpoint
3-
include Azu::Endpoint(Azu::Request::Empty, Auth::LogoutResponse | Azu::Response::Empty)
6+
include Azu::Endpoint(Auth::LogoutRequest, Auth::LogoutResponse)
47

58
post "/auth/logout"
69

7-
def call : Auth::LogoutResponse | Azu::Response::Empty
8-
<%- if using_session? %>
9-
session.delete("user_id")
10-
<%- end %>
10+
def call : Auth::LogoutResponse
11+
session.delete("_csrf_token") if respond_to?(:session)
1112
Auth::LogoutResponse.new
1213
end
1314
end

src/azu_cli/templates/auth/src/endpoints/auth/me_endpoint.cr.ecr

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,37 @@
11
<%# Requires first (Crystal convention) %>
22
require "../../models/user"
3+
require "../../requests/auth/me_request"
4+
require "../../response/auth/me_json"
35
<%- if using_jwt? || using_authly? %>
46
require "jwt"
57
<%- end %>
68

79
module <%= project.camelcase %>::Auth
810
struct MeEndpoint
9-
include Azu::Endpoint(Azu::Request::Empty, Auth::MeResponse | Azu::Response::Empty)
11+
include Azu::Endpoint(Auth::MeRequest, Auth::MeResponse | Azu::Response::Empty)
1012

1113
get "/auth/me"
1214

1315
def call : Auth::MeResponse | Azu::Response::Empty
1416
user = current_user
15-
halt 401, {error: "Not authenticated"}.to_json unless user
17+
unless user
18+
context.response.status = HTTP::Status::UNAUTHORIZED
19+
context.response.content_type = "application/json"
20+
context.response.print({error: "Not authenticated"}.to_json)
21+
return Azu::Response::Empty.new
22+
end
1623

1724
u = user.not_nil!
18-
data = {
19-
"id" => JSON::Any.new(u.id),
20-
"email" => JSON::Any.new(u.email),
21-
"name" => JSON::Any.new(u.name || ""),
22-
"role" => JSON::Any.new(u.role),
23-
} of String => JSON::Any
24-
Auth::MeResponse.new(data)
25+
Auth::MeResponse.new(u.id, u.email, u.name, u.role)
2526
end
2627

2728
private def current_user : ::User?
2829
<%- if using_jwt? || using_authly? %>
29-
token = request.headers["Authorization"]?.try(&.sub("Bearer ", ""))
30+
token = context.request.headers["Authorization"]?.try(&.sub("Bearer ", ""))
3031
return nil unless token
32+
3133
payload, header = JWT.decode(token, jwt_secret, JWT::Algorithm::HS256)
32-
user_id = payload["sub"]?.try(&.as_s).try(&.to_i64)
33-
return nil unless user_id
34-
::User.find(user_id)
34+
::User.find(payload["sub"].as_s.to_i64)
3535
<%- else %>
3636
id_val = session["user_id"]?
3737
return nil unless id_val

src/azu_cli/templates/auth/src/endpoints/auth/oauth_callback_endpoint.cr.ecr

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,39 @@ require "jwt"
88

99
module <%= project.camelcase %>::Auth
1010
struct OAuthCallbackEndpoint
11-
include Azu::Endpoint(Azu::Request::Empty, Azu::Response::Empty | Auth::LoginResponse)
11+
include Azu::Endpoint(Azu::Request, Azu::Response::Empty | Auth::LoginResponse)
1212

1313
get "/auth/oauth/:provider/callback"
1414

1515
def call : Azu::Response::Empty | Auth::LoginResponse
1616
provider = params[":provider"]? || params["provider"]?
1717
code = params["code"]?
1818
state = params["state"]?
19-
halt 400, {error: "provider required"}.to_json unless provider
20-
halt 400, {error: "code required"}.to_json unless code
19+
unless provider
20+
context.response.status = HTTP::Status::BAD_REQUEST
21+
context.response.content_type = "application/json"
22+
context.response.print({error: "provider required"}.to_json)
23+
return Azu::Response::Empty.new
24+
end
25+
unless code
26+
context.response.status = HTTP::Status::BAD_REQUEST
27+
context.response.content_type = "application/json"
28+
context.response.print({error: "code required"}.to_json)
29+
return Azu::Response::Empty.new
30+
end
2131

2232
callback = oauth_callback_url(provider)
2333
token = Authly.exchange_code(provider, code, callback, state)
2434
profile = Authly.fetch_profile(provider, token)
2535

2636
email = profile["email"]?.try(&.to_s)
2737
name = profile["name"]?.try(&.to_s)
28-
halt 422, {error: "email not available from provider"}.to_json unless email
38+
unless email
39+
context.response.status = HTTP::Status::UNPROCESSABLE_ENTITY
40+
context.response.content_type = "application/json"
41+
context.response.print({error: "email not available from provider"}.to_json)
42+
return Azu::Response::Empty.new
43+
end
2944

3045
user = ::User.find_by(email: email) || begin
3146
u = ::User.new

src/azu_cli/templates/auth/src/endpoints/auth/oauth_provider_endpoint.cr.ecr

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@ require "jwt"
88

99
module <%= project.camelcase %>::Auth
1010
struct OAuthProviderEndpoint
11-
include Azu::Endpoint(Azu::Request::Empty, Azu::Response::Empty | Auth::LoginResponse)
11+
include Azu::Endpoint(Azu::Request, Azu::Response::Empty | Auth::LoginResponse)
1212

1313
get "/auth/oauth/:provider"
1414

1515
def call : Azu::Response::Empty | Auth::LoginResponse
1616
provider = params[":provider"]? || params["provider"]?
17-
halt 400, {error: "provider required"}.to_json unless provider
17+
unless provider
18+
context.response.status = HTTP::Status::BAD_REQUEST
19+
context.response.content_type = "application/json"
20+
context.response.print({error: "provider required"}.to_json)
21+
return Azu::Response::Empty.new
22+
end
1823

1924
callback = oauth_callback_url(provider)
2025

src/azu_cli/templates/auth/src/endpoints/auth/permissions_endpoint.cr.ecr

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,40 @@
11
<%- if rbac_enabled? %>
22
<%# Requires first (Crystal convention) %>
33
require "../../models/user"
4+
require "../../requests/auth/permissions_request"
5+
require "../../response/auth/permissions_json"
46
<%- if using_jwt? || using_authly? %>
57
require "jwt"
68
<%- end %>
79

810
module <%= project.camelcase %>::Auth
911
struct PermissionsEndpoint
10-
include Azu::Endpoint(Azu::Request::Empty, Auth::PermissionsResponse | Azu::Response::Empty)
12+
include Azu::Endpoint(Auth::PermissionsRequest, Auth::PermissionsResponse | Azu::Response::Empty)
1113

1214
get "/auth/permissions"
1315

1416
def call : Auth::PermissionsResponse | Azu::Response::Empty
1517
user = current_user
16-
halt 401, {error: "Not authenticated"}.to_json unless user
18+
unless user
19+
context.response.status = HTTP::Status::UNAUTHORIZED
20+
context.response.content_type = "application/json"
21+
context.response.print({error: "Not authenticated"}.to_json)
22+
return Azu::Response::Empty.new
23+
end
1724

1825
u = user.not_nil!
19-
perms = u.permissions.map(&.name)
2026
roles = u.roles.map(&.name)
21-
Auth::PermissionsResponse.new(perms, roles)
27+
permissions = u.permissions.map(&.name)
28+
Auth::PermissionsResponse.new(roles, permissions)
2229
end
2330

2431
private def current_user : ::User?
2532
<%- if using_jwt? || using_authly? %>
26-
token = request.headers["Authorization"]?.try(&.sub("Bearer ", ""))
33+
token = context.request.headers["Authorization"]?.try(&.sub("Bearer ", ""))
2734
return nil unless token
35+
2836
payload, header = JWT.decode(token, jwt_secret, JWT::Algorithm::HS256)
29-
user_id = payload["sub"]?.try(&.as_s).try(&.to_i64)
30-
return nil unless user_id
31-
::User.find(user_id)
37+
::User.find(payload["sub"].as_s.to_i64)
3238
<%- else %>
3339
id_val = session["user_id"]?
3440
return nil unless id_val
Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<%- if using_jwt? || using_authly? %>
22
<%# Requires first (Crystal convention) %>
33
require "../../models/user"
4+
require "../../response/auth/refresh_json"
45
require "jwt"
56
67
module <%= project.camelcase %>::Auth
@@ -12,68 +13,79 @@ module <%= project.camelcase %>::Auth
1213
def call : Auth::RefreshResponse | Azu::Response::Empty
1314
req = refresh_token_request
1415
unless req.valid?
15-
halt 422, {errors: req.errors}.to_json
16+
context.response.status = HTTP::Status::UNPROCESSABLE_ENTITY
17+
context.response.content_type = "application/json"
18+
context.response.print({errors: req.errors}.to_json)
19+
return Azu::Response::Empty.new
1620
end
1721

22+
<%- if using_jwt? || using_authly? %>
1823
payload, header = JWT.decode(req.refresh_token, jwt_refresh_secret, JWT::Algorithm::HS256)
1924
unless payload["type"]? == "refresh"
20-
halt 401, {error: "Invalid refresh token"}.to_json
25+
context.response.status = HTTP::Status::UNAUTHORIZED
26+
context.response.content_type = "application/json"
27+
context.response.print({error: "Invalid refresh token"}.to_json)
28+
return Azu::Response::Empty.new
2129
end
2230

23-
user_id = payload["sub"]?.try(&.as_s).try(&.to_i64)
24-
user = user_id ? ::User.find(user_id) : nil
25-
unless user
26-
halt 401, {error: "User not found"}.to_json
27-
end
28-
29-
access = generate_access_token(user.not_nil!)
30-
refresh = generate_refresh_token(user.not_nil!)
31-
Auth::RefreshResponse.new(access, refresh)
32-
rescue JWT::Error
33-
halt 401, {error: "Invalid refresh token"}.to_json
31+
access = generate_access_from_payload(payload)
32+
new_refresh = generate_refresh_from_payload(payload)
33+
Auth::RefreshResponse.new(access, new_refresh)
34+
<%- else %>
35+
context.response.status = HTTP::Status::BAD_REQUEST
36+
context.response.content_type = "application/json"
37+
context.response.print({error: "Refresh tokens require JWT strategy"}.to_json)
38+
return Azu::Response::Empty.new
39+
<%- end %>
3440
end
3541

36-
private def generate_access_token(user : ::User) : String
42+
<%- if using_jwt? || using_authly? %>
43+
private def generate_access_from_payload(payload : Hash(String, JSON::Any)) : String
3744
now = Time.utc
38-
payload = {
39-
"sub" => user.id.to_s,
45+
sub = payload["sub"].as_s
46+
role = payload["role"]?.try(&.as_s) || "user"
47+
new_payload = {
48+
"sub" => sub,
4049
"iat" => now.to_unix,
4150
"exp" => (now + 15.minutes).to_unix,
4251
"iss" => jwt_issuer,
4352
"aud" => jwt_audience,
44-
"role" => user.role,
53+
"role" => role,
4554
}
46-
JWT.encode(payload, jwt_secret, JWT::Algorithm::HS256)
55+
JWT.encode(new_payload, jwt_secret, JWT::Algorithm::HS256)
4756
end
4857

49-
private def generate_refresh_token(user : ::User) : String
58+
private def generate_refresh_from_payload(payload : Hash(String, JSON::Any)) : String
5059
now = Time.utc
51-
payload = {
52-
"sub" => user.id.to_s,
60+
sub = payload["sub"].as_s
61+
new_payload = {
62+
"sub" => sub,
5363
"iat" => now.to_unix,
5464
"exp" => (now + 7.days).to_unix,
5565
"type" => "refresh",
5666
}
57-
JWT.encode(payload, jwt_refresh_secret, JWT::Algorithm::HS256)
58-
end
59-
60-
private def jwt_secret : String
61-
ENV["JWT_SECRET"]? || raise "JWT_SECRET environment variable not set"
67+
JWT.encode(new_payload, jwt_refresh_secret, JWT::Algorithm::HS256)
6268
end
6369

6470
private def jwt_refresh_secret : String
6571
ENV["JWT_REFRESH_SECRET"]? || raise "JWT_REFRESH_SECRET environment variable not set"
6672
end
6773

74+
private def jwt_secret : String
75+
ENV["JWT_SECRET"]? || raise "JWT_SECRET environment variable not set"
76+
end
77+
6878
private def jwt_issuer : String
6979
ENV["JWT_ISSUER"]? || "<%= project.downcase %>-api"
7080
end
7181

7282
private def jwt_audience : String
7383
ENV["JWT_AUDIENCE"]? || "<%= project.downcase %>-client"
7484
end
85+
<%- end %>
7586
end
7687
end
88+
7789
<%- end %>
7890

7991

0 commit comments

Comments
 (0)