feat(backend): implement auth handler, middleware, and router
domain/context.go: extend WithUser/UserFromContext with session ID handler/response.go: respondJSON/respondError with domain→HTTP status mapping (404/403/401/409/400/415/500) handler/middleware.go: Bearer JWT extraction, ParseAccessToken, domain.WithUser injection; aborts with 401 JSON on failure handler/auth_handler.go: Login, Refresh, Logout, ListSessions, TerminateSession handler/router.go: /health, /api/v1/auth routes; login and refresh are public, session routes protected by AuthMiddleware Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
277f42035c
commit
1766dc2b3c
@ -7,18 +7,26 @@ type ctxKey int
|
|||||||
const userKey ctxKey = iota
|
const userKey ctxKey = iota
|
||||||
|
|
||||||
type contextUser struct {
|
type contextUser struct {
|
||||||
ID int16
|
ID int16
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
SessionID int
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context {
|
// WithUser stores user identity and current session ID in ctx.
|
||||||
return context.WithValue(ctx, userKey, contextUser{ID: userID, IsAdmin: isAdmin})
|
func WithUser(ctx context.Context, userID int16, isAdmin bool, sessionID int) context.Context {
|
||||||
|
return context.WithValue(ctx, userKey, contextUser{
|
||||||
|
ID: userID,
|
||||||
|
IsAdmin: isAdmin,
|
||||||
|
SessionID: sessionID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) {
|
// UserFromContext retrieves user identity from ctx.
|
||||||
|
// Returns zero values if no user is stored.
|
||||||
|
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool, sessionID int) {
|
||||||
u, ok := ctx.Value(userKey).(contextUser)
|
u, ok := ctx.Value(userKey).(contextUser)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, false
|
return 0, false, 0
|
||||||
}
|
}
|
||||||
return u.ID, u.IsAdmin
|
return u.ID, u.IsAdmin, u.SessionID
|
||||||
}
|
}
|
||||||
|
|||||||
140
backend/internal/handler/auth_handler.go
Normal file
140
backend/internal/handler/auth_handler.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthHandler handles all /auth endpoints.
|
||||||
|
type AuthHandler struct {
|
||||||
|
authSvc *service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthHandler creates an AuthHandler backed by authSvc.
|
||||||
|
func NewAuthHandler(authSvc *service.AuthService) *AuthHandler {
|
||||||
|
return &AuthHandler{authSvc: authSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login handles POST /auth/login.
|
||||||
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pair, err := h.authSvc.Login(c.Request.Context(), req.Name, req.Password, c.GetHeader("User-Agent"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, gin.H{
|
||||||
|
"access_token": pair.AccessToken,
|
||||||
|
"refresh_token": pair.RefreshToken,
|
||||||
|
"expires_in": pair.ExpiresIn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh handles POST /auth/refresh.
|
||||||
|
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pair, err := h.authSvc.Refresh(c.Request.Context(), req.RefreshToken, c.GetHeader("User-Agent"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, gin.H{
|
||||||
|
"access_token": pair.AccessToken,
|
||||||
|
"refresh_token": pair.RefreshToken,
|
||||||
|
"expires_in": pair.ExpiresIn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout handles POST /auth/logout. Requires authentication.
|
||||||
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
|
_, _, sessionID := domain.UserFromContext(c.Request.Context())
|
||||||
|
|
||||||
|
if err := h.authSvc.Logout(c.Request.Context(), sessionID); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions handles GET /auth/sessions. Requires authentication.
|
||||||
|
func (h *AuthHandler) ListSessions(c *gin.Context) {
|
||||||
|
userID, _, sessionID := domain.UserFromContext(c.Request.Context())
|
||||||
|
|
||||||
|
list, err := h.authSvc.ListSessions(c.Request.Context(), userID, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
ExpiresAt any `json:"expires_at"`
|
||||||
|
LastActivity string `json:"last_activity"`
|
||||||
|
IsCurrent bool `json:"is_current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]sessionItem, len(list.Items))
|
||||||
|
for i, s := range list.Items {
|
||||||
|
var expiresAt any
|
||||||
|
if s.ExpiresAt != nil {
|
||||||
|
expiresAt = s.ExpiresAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
}
|
||||||
|
items[i] = sessionItem{
|
||||||
|
ID: s.ID,
|
||||||
|
UserAgent: s.UserAgent,
|
||||||
|
StartedAt: s.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
LastActivity: s.LastActivity.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
IsCurrent: s.IsCurrent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"total": list.Total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminateSession handles DELETE /auth/sessions/:id. Requires authentication.
|
||||||
|
func (h *AuthHandler) TerminateSession(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
|
||||||
|
|
||||||
|
if err := h.authSvc.TerminateSession(c.Request.Context(), userID, isAdmin, id); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
52
backend/internal/handler/middleware.go
Normal file
52
backend/internal/handler/middleware.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware validates Bearer JWTs and injects user identity into context.
|
||||||
|
type AuthMiddleware struct {
|
||||||
|
authSvc *service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthMiddleware creates an AuthMiddleware backed by authSvc.
|
||||||
|
func NewAuthMiddleware(authSvc *service.AuthService) *AuthMiddleware {
|
||||||
|
return &AuthMiddleware{authSvc: authSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle returns a Gin handler function that enforces authentication.
|
||||||
|
// On success it calls c.Next(); on failure it aborts with 401 JSON.
|
||||||
|
func (m *AuthMiddleware) Handle() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
raw := c.GetHeader("Authorization")
|
||||||
|
if !strings.HasPrefix(raw, "Bearer ") {
|
||||||
|
c.JSON(http.StatusUnauthorized, errorBody{
|
||||||
|
Code: domain.ErrUnauthorized.Code(),
|
||||||
|
Message: "authorization header missing or malformed",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := strings.TrimPrefix(raw, "Bearer ")
|
||||||
|
|
||||||
|
claims, err := m.authSvc.ParseAccessToken(token)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, errorBody{
|
||||||
|
Code: domain.ErrUnauthorized.Code(),
|
||||||
|
Message: "invalid or expired token",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
|
||||||
|
c.Request = c.Request.WithContext(ctx)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/internal/handler/response.go
Normal file
55
backend/internal/handler/response.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errorBody is the JSON shape returned for all error responses.
|
||||||
|
type errorBody struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondJSON(c *gin.Context, status int, data any) {
|
||||||
|
c.JSON(status, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondError maps a domain error to the appropriate HTTP status and writes
|
||||||
|
// a JSON error body. Unknown errors become 500.
|
||||||
|
func respondError(c *gin.Context, err error) {
|
||||||
|
var de *domain.DomainError
|
||||||
|
if errors.As(err, &de) {
|
||||||
|
c.JSON(domainStatus(de), errorBody{Code: de.Code(), Message: de.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, errorBody{
|
||||||
|
Code: "internal_error",
|
||||||
|
Message: "internal server error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainStatus maps a DomainError sentinel to its HTTP status code per the
|
||||||
|
// error mapping table in docs/GO_PROJECT_STRUCTURE.md.
|
||||||
|
func domainStatus(de *domain.DomainError) int {
|
||||||
|
switch de {
|
||||||
|
case domain.ErrNotFound:
|
||||||
|
return http.StatusNotFound
|
||||||
|
case domain.ErrForbidden:
|
||||||
|
return http.StatusForbidden
|
||||||
|
case domain.ErrUnauthorized:
|
||||||
|
return http.StatusUnauthorized
|
||||||
|
case domain.ErrConflict:
|
||||||
|
return http.StatusConflict
|
||||||
|
case domain.ErrValidation:
|
||||||
|
return http.StatusBadRequest
|
||||||
|
case domain.ErrUnsupportedMIME:
|
||||||
|
return http.StatusUnsupportedMediaType
|
||||||
|
default:
|
||||||
|
return http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/internal/handler/router.go
Normal file
37
backend/internal/handler/router.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRouter builds and returns a configured Gin engine.
|
||||||
|
// Additional handlers will be added here as they are implemented.
|
||||||
|
func NewRouter(auth *AuthMiddleware, authHandler *AuthHandler) *gin.Engine {
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
|
|
||||||
|
// Health check — no auth required.
|
||||||
|
r.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
v1 := r.Group("/api/v1")
|
||||||
|
|
||||||
|
// Auth endpoints — login and refresh are public; others require a valid token.
|
||||||
|
authGroup := v1.Group("/auth")
|
||||||
|
{
|
||||||
|
authGroup.POST("/login", authHandler.Login)
|
||||||
|
authGroup.POST("/refresh", authHandler.Refresh)
|
||||||
|
|
||||||
|
protected := authGroup.Group("", auth.Handle())
|
||||||
|
{
|
||||||
|
protected.POST("/logout", authHandler.Logout)
|
||||||
|
protected.GET("/sessions", authHandler.ListSessions)
|
||||||
|
protected.DELETE("/sessions/:id", authHandler.TerminateSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user