248 lines
5.7 KiB
Go
248 lines
5.7 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"xcontrol/account/internal/store"
|
|
)
|
|
|
|
const sessionTTL = 24 * time.Hour
|
|
|
|
type session struct {
|
|
userID string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
type handler struct {
|
|
store store.Store
|
|
sessions map[string]session
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// RegisterRoutes attaches account service endpoints to the router.
|
|
func RegisterRoutes(r *gin.Engine) {
|
|
h := &handler{
|
|
store: store.NewMemoryStore(),
|
|
sessions: make(map[string]session),
|
|
}
|
|
|
|
r.GET("/healthz", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
})
|
|
|
|
v1 := r.Group("/v1")
|
|
v1.POST("/register", h.register)
|
|
v1.POST("/login", h.login)
|
|
v1.GET("/session", h.session)
|
|
v1.DELETE("/session", h.deleteSession)
|
|
}
|
|
|
|
type registerRequest struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func (h *handler) register(c *gin.Context) {
|
|
var req registerRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(req.Name)
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
password := strings.TrimSpace(req.Password)
|
|
|
|
if email == "" || password == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
|
|
return
|
|
}
|
|
|
|
if !strings.Contains(email, "@") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "email must be a valid address"})
|
|
return
|
|
}
|
|
|
|
if len(password) < 8 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "password must be at least 8 characters"})
|
|
return
|
|
}
|
|
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to secure password"})
|
|
return
|
|
}
|
|
|
|
user := &store.User{
|
|
Name: name,
|
|
Email: email,
|
|
PasswordHash: string(hashed),
|
|
}
|
|
|
|
if err := h.store.CreateUser(c.Request.Context(), user); err != nil {
|
|
if errors.Is(err, store.ErrUserExists) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "user already exists"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
|
|
return
|
|
}
|
|
|
|
response := gin.H{"user": sanitizeUser(user)}
|
|
c.JSON(http.StatusCreated, response)
|
|
}
|
|
|
|
type loginRequest struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func (h *handler) login(c *gin.Context) {
|
|
var req loginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
|
|
return
|
|
}
|
|
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
password := strings.TrimSpace(req.Password)
|
|
if email == "" || password == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
|
|
return
|
|
}
|
|
|
|
user, err := h.store.GetUserByEmail(c.Request.Context(), email)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrUserNotFound) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to authenticate"})
|
|
return
|
|
}
|
|
|
|
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
|
|
token, expiresAt, err := h.createSession(user.ID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"token": token,
|
|
"expiresAt": expiresAt.UTC(),
|
|
"user": sanitizeUser(user),
|
|
})
|
|
}
|
|
|
|
func (h *handler) session(c *gin.Context) {
|
|
token := extractToken(c.GetHeader("Authorization"))
|
|
if token == "" {
|
|
if value := c.Query("token"); value != "" {
|
|
token = value
|
|
}
|
|
}
|
|
if token == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "session token required"})
|
|
return
|
|
}
|
|
|
|
sess, ok := h.lookupSession(token)
|
|
if !ok {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "session not found"})
|
|
return
|
|
}
|
|
|
|
user, err := h.store.GetUserByID(c.Request.Context(), sess.userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load session user"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"user": sanitizeUser(user)})
|
|
}
|
|
|
|
func (h *handler) deleteSession(c *gin.Context) {
|
|
token := extractToken(c.GetHeader("Authorization"))
|
|
if token == "" {
|
|
if value := c.Query("token"); value != "" {
|
|
token = value
|
|
}
|
|
}
|
|
if token == "" {
|
|
c.Status(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
h.removeSession(token)
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *handler) createSession(userID string) (string, time.Time, error) {
|
|
buffer := make([]byte, 32)
|
|
if _, err := rand.Read(buffer); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
token := hex.EncodeToString(buffer)
|
|
expiresAt := time.Now().Add(sessionTTL)
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.sessions[token] = session{userID: userID, expiresAt: expiresAt}
|
|
return token, expiresAt, nil
|
|
}
|
|
|
|
func (h *handler) lookupSession(token string) (session, bool) {
|
|
h.mu.RLock()
|
|
sess, ok := h.sessions[token]
|
|
h.mu.RUnlock()
|
|
if !ok {
|
|
return session{}, false
|
|
}
|
|
if time.Now().After(sess.expiresAt) {
|
|
h.removeSession(token)
|
|
return session{}, false
|
|
}
|
|
return sess, true
|
|
}
|
|
|
|
func (h *handler) removeSession(token string) {
|
|
h.mu.Lock()
|
|
delete(h.sessions, token)
|
|
h.mu.Unlock()
|
|
}
|
|
|
|
func sanitizeUser(user *store.User) gin.H {
|
|
return gin.H{
|
|
"id": user.ID,
|
|
"name": user.Name,
|
|
"email": user.Email,
|
|
}
|
|
}
|
|
|
|
func extractToken(header string) string {
|
|
if header == "" {
|
|
return ""
|
|
}
|
|
const prefix = "Bearer "
|
|
if strings.HasPrefix(header, prefix) {
|
|
header = header[len(prefix):]
|
|
}
|
|
return strings.TrimSpace(header)
|
|
}
|