Skip to content

Commit 29e7a0a

Browse files
committed
Merge branch 'feature-captcha' into feature-department
2 parents 4742739 + 33055d1 commit 29e7a0a

37 files changed

Lines changed: 2197 additions & 241 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,4 @@ shadmin.exe
8585
.logs
8686
.database
8787
.examples
88-
.cli/bin/*
88+
/cli/shadmin-cli
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
package controller
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
"sync"
7+
"time"
8+
9+
"shadmin/domain"
10+
"shadmin/internal/constants"
11+
12+
"github.com/gin-gonic/gin"
13+
)
14+
15+
type DeviceAuthController struct {
16+
DeviceAuthUsecase domain.DeviceAuthUsecase
17+
requestLimiter *deviceAuthRateLimiter
18+
pollLimiter *deviceAuthRateLimiter
19+
activateLimiter *deviceAuthRateLimiter
20+
}
21+
22+
func NewDeviceAuthController(deviceAuthUsecase domain.DeviceAuthUsecase) *DeviceAuthController {
23+
return &DeviceAuthController{
24+
DeviceAuthUsecase: deviceAuthUsecase,
25+
requestLimiter: newDeviceAuthRateLimiter(10, time.Minute),
26+
pollLimiter: newDeviceAuthRateLimiter(60, time.Minute),
27+
activateLimiter: newDeviceAuthRateLimiter(20, time.Minute),
28+
}
29+
}
30+
31+
// RequestCode godoc
32+
// @Summary Request device authorization code
33+
// @Description Create a device authorization session for CLI login
34+
// @Tags Authentication
35+
// @Accept json
36+
// @Produce json
37+
// @Param request body domain.DeviceCodeRequest true "Device code request"
38+
// @Success 200 {object} domain.Response{data=domain.DeviceCodeResponse}
39+
// @Failure 400 {object} domain.Response
40+
// @Failure 500 {object} domain.Response
41+
// @Router /auth/device/code [post]
42+
func (dc *DeviceAuthController) RequestCode(c *gin.Context) {
43+
if !dc.allow(c, dc.requestLimiter) {
44+
return
45+
}
46+
47+
var request domain.DeviceCodeRequest
48+
if !MustBindJSON(c, &request) {
49+
return
50+
}
51+
52+
resp, err := dc.DeviceAuthUsecase.RequestCode(c, request, "/auth/device")
53+
if err != nil {
54+
c.JSON(http.StatusInternalServerError, domain.RespError(err.Error()))
55+
return
56+
}
57+
c.JSON(http.StatusOK, domain.RespSuccess(resp))
58+
}
59+
60+
// PollToken godoc
61+
// @Summary Poll device authorization token
62+
// @Description Poll until user authorizes a device code, then return JWT tokens
63+
// @Tags Authentication
64+
// @Accept json
65+
// @Produce json
66+
// @Param request body domain.DeviceTokenRequest true "Device token request"
67+
// @Success 200 {object} domain.Response{data=domain.LoginResponse}
68+
// @Failure 400 {object} domain.Response
69+
// @Failure 500 {object} domain.Response
70+
// @Router /auth/device/token [post]
71+
func (dc *DeviceAuthController) PollToken(c *gin.Context) {
72+
if !dc.allow(c, dc.pollLimiter) {
73+
return
74+
}
75+
76+
var request domain.DeviceTokenRequest
77+
if !MustBindJSON(c, &request) {
78+
return
79+
}
80+
81+
resp, err := dc.DeviceAuthUsecase.PollToken(c, request)
82+
if err != nil {
83+
status := http.StatusBadRequest
84+
switch {
85+
case errors.Is(err, domain.ErrDeviceAuthorizationPending),
86+
errors.Is(err, domain.ErrDeviceSlowDown),
87+
errors.Is(err, domain.ErrDeviceExpired),
88+
errors.Is(err, domain.ErrDeviceConsumed),
89+
errors.Is(err, domain.ErrDeviceAccessDenied),
90+
errors.Is(err, domain.ErrDeviceInvalidCode):
91+
status = http.StatusBadRequest
92+
default:
93+
status = http.StatusInternalServerError
94+
}
95+
c.JSON(status, domain.RespError(err.Error()))
96+
return
97+
}
98+
c.JSON(http.StatusOK, domain.RespSuccess(resp))
99+
}
100+
101+
// Activate godoc
102+
// @Summary Activate device authorization code
103+
// @Description Authorize a CLI device code using the current web user session
104+
// @Tags Authentication
105+
// @Accept json
106+
// @Produce json
107+
// @Param request body domain.DeviceActivateRequest true "Device activation request"
108+
// @Success 200 {object} domain.Response{data=domain.DeviceActivateResponse}
109+
// @Failure 400 {object} domain.Response
110+
// @Failure 401 {object} domain.Response
111+
// @Failure 500 {object} domain.Response
112+
// @Router /auth/device/activate [post]
113+
func (dc *DeviceAuthController) Activate(c *gin.Context) {
114+
if !dc.allow(c, dc.activateLimiter) {
115+
return
116+
}
117+
118+
var request domain.DeviceActivateRequest
119+
if !MustBindJSON(c, &request) {
120+
return
121+
}
122+
123+
userIDValue, exists := c.Get(constants.UserID)
124+
if !exists {
125+
c.JSON(http.StatusUnauthorized, domain.RespError("Not authorized"))
126+
return
127+
}
128+
userID, ok := userIDValue.(string)
129+
if !ok || userID == "" {
130+
c.JSON(http.StatusUnauthorized, domain.RespError("Invalid user context"))
131+
return
132+
}
133+
134+
resp, err := dc.DeviceAuthUsecase.Activate(c, userID, request)
135+
if err != nil {
136+
status := http.StatusBadRequest
137+
if !errors.Is(err, domain.ErrDeviceInvalidCode) &&
138+
!errors.Is(err, domain.ErrDeviceExpired) &&
139+
!errors.Is(err, domain.ErrDeviceAccessDenied) {
140+
status = http.StatusInternalServerError
141+
}
142+
c.JSON(status, domain.RespError(err.Error()))
143+
return
144+
}
145+
c.JSON(http.StatusOK, domain.RespSuccess(resp))
146+
}
147+
148+
func (dc *DeviceAuthController) allow(c *gin.Context, limiter *deviceAuthRateLimiter) bool {
149+
if limiter == nil || limiter.Allow(c.ClientIP()) {
150+
return true
151+
}
152+
c.JSON(http.StatusTooManyRequests, domain.RespError("too_many_requests"))
153+
return false
154+
}
155+
156+
// Close is kept for callers that manage controller lifecycles.
157+
func (dc *DeviceAuthController) Close() {
158+
dc.requestLimiter.close()
159+
dc.pollLimiter.close()
160+
dc.activateLimiter.close()
161+
}
162+
163+
type deviceAuthRateLimiter struct {
164+
mu sync.Mutex
165+
limit int
166+
window time.Duration
167+
hits map[string]*deviceAuthRateLimitEntry
168+
lastCleanup time.Time
169+
}
170+
171+
type deviceAuthRateLimitEntry struct {
172+
count int
173+
windowStart time.Time
174+
}
175+
176+
func newDeviceAuthRateLimiter(limit int, window time.Duration) *deviceAuthRateLimiter {
177+
l := &deviceAuthRateLimiter{
178+
limit: limit,
179+
window: window,
180+
hits: make(map[string]*deviceAuthRateLimitEntry),
181+
lastCleanup: time.Now(),
182+
}
183+
return l
184+
}
185+
186+
func (l *deviceAuthRateLimiter) cleanupExpiredLocked(now time.Time) {
187+
for key, entry := range l.hits {
188+
if now.Sub(entry.windowStart) >= l.window {
189+
delete(l.hits, key)
190+
}
191+
}
192+
}
193+
194+
// close is kept for controller lifecycle compatibility.
195+
func (l *deviceAuthRateLimiter) close() {
196+
}
197+
198+
func (l *deviceAuthRateLimiter) Allow(key string) bool {
199+
if key == "" {
200+
key = "unknown"
201+
}
202+
now := time.Now()
203+
l.mu.Lock()
204+
defer l.mu.Unlock()
205+
if now.Sub(l.lastCleanup) >= l.window {
206+
l.cleanupExpiredLocked(now)
207+
l.lastCleanup = now
208+
}
209+
210+
entry, ok := l.hits[key]
211+
if !ok || now.Sub(entry.windowStart) >= l.window {
212+
l.hits[key] = &deviceAuthRateLimitEntry{count: 1, windowStart: now}
213+
return true
214+
}
215+
if entry.count >= l.limit {
216+
return false
217+
}
218+
entry.count++
219+
return true
220+
}

api/route/factory.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ import (
1919

2020
// ControllerFactory creates and manages controller instances
2121
type ControllerFactory struct {
22-
app *bootstrap.Application
23-
timeout time.Duration
24-
db *ent.Client
25-
captchaManager *captchapkg.SlideManager
22+
app *bootstrap.Application
23+
timeout time.Duration
24+
db *ent.Client
25+
captchaManager *captchapkg.SlideManager
26+
deviceAuthController *controller.DeviceAuthController
2627
}
2728

2829
// NewControllerFactory creates a new controller factory
@@ -65,6 +66,30 @@ func (f *ControllerFactory) CreateCaptchaController() *controller.CaptchaControl
6566
}
6667
}
6768

69+
// CreateDeviceAuthController creates a device authorization controller
70+
func (f *ControllerFactory) CreateDeviceAuthController() *controller.DeviceAuthController {
71+
if f.deviceAuthController != nil {
72+
return f.deviceAuthController
73+
}
74+
deviceAuthRepository := repository.NewDeviceAuthRepository(f.db)
75+
userRepository := repository.NewUserRepository(f.db, f.app.CasManager)
76+
tokenService := tokenservice.NewTokenService()
77+
78+
f.deviceAuthController = controller.NewDeviceAuthController(
79+
usecase.NewDeviceAuthUsecase(
80+
deviceAuthRepository,
81+
userRepository,
82+
tokenService,
83+
f.app.Env.AccessTokenSecret,
84+
f.app.Env.RefreshTokenSecret,
85+
f.app.Env.AccessTokenExpiryMinute,
86+
f.app.Env.RefreshTokenExpiryMinute,
87+
f.timeout,
88+
),
89+
)
90+
return f.deviceAuthController
91+
}
92+
6893
// CreateProfileController creates a profile controller
6994
func (f *ControllerFactory) CreateProfileController() *controller.ProfileController {
7095
pr := repository.NewProfileRepository(f.db)

api/route/protected.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package route
33
import (
44
"shadmin/api/middleware"
55
"shadmin/bootstrap"
6-
"time"
76

87
"github.com/gin-gonic/gin"
98
)
@@ -14,9 +13,9 @@ type ProtectedRoutes struct {
1413
}
1514

1615
// NewProtectedRoutes creates a new protected routes manager
17-
func NewProtectedRoutes(app *bootstrap.Application, timeout time.Duration) *ProtectedRoutes {
16+
func NewProtectedRoutes(factory *ControllerFactory) *ProtectedRoutes {
1817
return &ProtectedRoutes{
19-
factory: NewControllerFactory(app, timeout, app.DB),
18+
factory: factory,
2019
}
2120
}
2221

@@ -41,6 +40,9 @@ func (pr *ProtectedRoutes) setupUserRoutes(router *gin.RouterGroup, app *bootstr
4140
resourcesGroup := router.Group("/resources")
4241
pr.setupResourceRoutes(resourcesGroup)
4342

43+
// Device authorization activation (authenticated, no Casbin menu permission required)
44+
deviceAuthGroup := router.Group("/auth/device")
45+
pr.setupDeviceAuthRoutes(deviceAuthGroup)
4446
}
4547

4648
// setupProfileRoutes configures profile management routes
@@ -59,6 +61,13 @@ func (pr *ProtectedRoutes) setupResourceRoutes(group *gin.RouterGroup) {
5961
group.GET("", resourceController.GetResources)
6062
}
6163

64+
// setupDeviceAuthRoutes configures authenticated device authorization routes.
65+
func (pr *ProtectedRoutes) setupDeviceAuthRoutes(group *gin.RouterGroup) {
66+
deviceAuthController := pr.factory.CreateDeviceAuthController()
67+
68+
group.POST("/activate", deviceAuthController.Activate)
69+
}
70+
6271
// setupSystemRoutes configures system administration routes
6372
func (pr *ProtectedRoutes) setupSystemRoutes(router *gin.RouterGroup, app *bootstrap.Application, engine *gin.Engine) {
6473
casbinMiddleware := middleware.NewCasbinMiddleware(app.CasManager)

api/route/public.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package route
33
import (
44
"shadmin/api/middleware"
55
"shadmin/bootstrap"
6-
"time"
76

87
"github.com/gin-gonic/gin"
98
)
@@ -14,9 +13,9 @@ type PublicRoutes struct {
1413
}
1514

1615
// NewPublicRoutes creates a new public routes manager
17-
func NewPublicRoutes(app *bootstrap.Application, timeout time.Duration) *PublicRoutes {
16+
func NewPublicRoutes(factory *ControllerFactory) *PublicRoutes {
1817
return &PublicRoutes{
19-
factory: NewControllerFactory(app, timeout, app.DB),
18+
factory: factory,
2019
}
2120
}
2221

@@ -46,9 +45,12 @@ func (pr *PublicRoutes) setupHealthRoutes(group *gin.RouterGroup) {
4645
func (pr *PublicRoutes) setupAuthRoutes(group *gin.RouterGroup, app *bootstrap.Application) {
4746
authController := pr.factory.CreateAuthController(app.CasManager)
4847
captchaController := pr.factory.CreateCaptchaController()
48+
deviceAuthController := pr.factory.CreateDeviceAuthController()
4949

5050
group.POST("/login", authController.Login)
5151
group.POST("/refresh", authController.RefreshToken)
5252
group.POST("/logout", authController.Logout)
5353
group.GET("/captcha/slide", captchaController.GetSlideCaptcha)
54+
group.POST("/device/code", deviceAuthController.RequestCode)
55+
group.POST("/device/token", deviceAuthController.PollToken)
5456
}

api/route/route.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ func Setup(app *bootstrap.Application, timeout time.Duration, engine *gin.Engine
1919
}
2020

2121
// Register static assets (skip in development — Vite dev server handles frontend)
22-
if app.Env.AppEnv != "development" {
23-
web.Register(engine)
24-
}
22+
//if app.Env.AppEnv != "development" {
23+
web.Register(engine)
24+
//}
2525
setupSwagger(engine)
2626

2727
// Setup API routes
@@ -33,12 +33,13 @@ func Setup(app *bootstrap.Application, timeout time.Duration, engine *gin.Engine
3333
// setupApiRoutes configures all API routes
3434
func setupApiRoutes(app *bootstrap.Application, timeout time.Duration, engine *gin.Engine) {
3535
apiV1 := engine.Group(ApiUri)
36+
factory := NewControllerFactory(app, timeout, app.DB)
3637

3738
// Setup public routes (no authentication required)
38-
publicRoutes := NewPublicRoutes(app, timeout)
39+
publicRoutes := NewPublicRoutes(factory)
3940
publicRoutes.Setup(apiV1, app)
4041

4142
// Setup protected routes (authentication required)
42-
protectedRoutes := NewProtectedRoutes(app, timeout)
43+
protectedRoutes := NewProtectedRoutes(factory)
4344
protectedRoutes.Setup(apiV1, app, engine)
4445
}

cli/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# shadmin-cli local config
2+
# Copy to cli/.env for repo-local development, or set SHADMIN_CONFIG to another .env path.
3+
4+
# Equivalent to --server. Used by device login and all API calls.
5+
SHADMIN_SERVER=http://localhost:55667

0 commit comments

Comments
 (0)