diff --git a/backend/internal/domain/context.go b/backend/internal/domain/context.go index 2998c9a..1365d96 100644 --- a/backend/internal/domain/context.go +++ b/backend/internal/domain/context.go @@ -7,18 +7,26 @@ type ctxKey int const userKey ctxKey = iota type contextUser struct { - ID int16 - IsAdmin bool + ID int16 + IsAdmin bool + SessionID int } -func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context { - return context.WithValue(ctx, userKey, contextUser{ID: userID, IsAdmin: isAdmin}) +// WithUser stores user identity and current session ID in ctx. +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) if !ok { - return 0, false + return 0, false, 0 } - return u.ID, u.IsAdmin + return u.ID, u.IsAdmin, u.SessionID } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go new file mode 100644 index 0000000..011a4ae --- /dev/null +++ b/backend/internal/handler/auth_handler.go @@ -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) +} diff --git a/backend/internal/handler/middleware.go b/backend/internal/handler/middleware.go new file mode 100644 index 0000000..d5b0869 --- /dev/null +++ b/backend/internal/handler/middleware.go @@ -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() + } +} diff --git a/backend/internal/handler/response.go b/backend/internal/handler/response.go new file mode 100644 index 0000000..8bb3d0e --- /dev/null +++ b/backend/internal/handler/response.go @@ -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 + } +} diff --git a/backend/internal/handler/router.go b/backend/internal/handler/router.go new file mode 100644 index 0000000..a4dd657 --- /dev/null +++ b/backend/internal/handler/router.go @@ -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 +}