diff --git a/api.go b/api.go index b673708..a1e3cec 100644 --- a/api.go +++ b/api.go @@ -294,7 +294,7 @@ func (aH *AccessHandlerAPI) DeleteRole(c *gin.Context) { err = aH.dbHandler.DeleteById(&models.Role{}, removeIds...) if err != nil { - aH.logger.Error("DeleteUser", err) + aH.logger.Error("DeleteRole", err) c.JSON(http.StatusInternalServerError, nil) return } diff --git a/loginApi.go b/loginApi.go new file mode 100644 index 0000000..b0ff730 --- /dev/null +++ b/loginApi.go @@ -0,0 +1,227 @@ +package AccessHandler + +import ( + "fmt" + "log" + "net/http" + "time" + + "gitea.tecamino.com/paadi/access-handler/models" + "gitea.tecamino.com/paadi/access-handler/utils" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// ----------------------------- +// 🧠 HANDLERS +// ----------------------------- + +// Login authenticates a user and returns JWT tokens (access + refresh). +func (aH *AccessHandlerAPI) Login(c *gin.Context) { + var user models.User + + aH.logger.Debug("Login", "bind JSON request") + if err := c.BindJSON(&user); err != nil { + aH.logger.Error("Login", err) + c.JSON(http.StatusBadRequest, models.NewJsonErrorResponse(err)) + return + } + + // Validate input + if !user.IsValid() { + aH.logger.Error("Login", "user empty") + c.JSON(http.StatusBadRequest, models.NewJsonMessageResponse("user empty")) + return + } + + // Fetch user record from DB + var dbRecord []models.User + err := aH.dbHandler.GetByKey(&dbRecord, "user_name", user.Name, false) + if err != nil { + aH.logger.Error("Login", err) + c.JSON(http.StatusBadRequest, models.NewJsonErrorResponse(err)) + return + } + + if len(dbRecord) > 1 { + log.Println("multiple users found") + aH.logger.Error("Login", "more than one record found") + c.JSON(http.StatusInternalServerError, models.NewJsonMessageResponse("internal error")) + return + } + + // Check password + if !utils.CheckPassword(user.Password, dbRecord[0].Password) { + aH.logger.Error("Login", "invalid password") + c.JSON(http.StatusUnauthorized, models.NewJsonMessageResponse("invalid credentials")) + return + } + user = dbRecord[0] + + // ----------------------------- + // 🔑 TOKEN CREATION + // ----------------------------- + aH.logger.Debug("Login", "create tokens") + + accessTokenExp := time.Now().Add(ACCESS_TOKEN_TIME) + refreshTokenExp := time.Now().Add(REFRESH_TOKEN_TIME) + + // Create access token + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "id": user.Id, + "username": user.Name, + "role": user.Role, + "type": "access", + "exp": accessTokenExp.Unix(), + }) + + // Create refresh token + refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "id": user.Id, + "username": user.Name, + "role": user.Role, + "type": "refresh", + "exp": refreshTokenExp.Unix(), + }) + + // Sign tokens + accessString, err := accessToken.SignedString(ACCESS_SECRET) + if err != nil { + aH.logger.Error("Login", "failed to sign access token") + c.JSON(http.StatusInternalServerError, models.NewJsonMessageResponse("could not create access token")) + return + } + + refreshString, err := refreshToken.SignedString(REFRESH_SECRET) + if err != nil { + aH.logger.Error("Login", "failed to sign refresh token") + c.JSON(http.StatusInternalServerError, models.NewJsonMessageResponse("could not create refresh token")) + return + } + + // ----------------------------- + // 🍪 SET COOKIES + // ----------------------------- + aH.logger.Debug("Login", "set cookies") + + secure := gin.Mode() == gin.ReleaseMode + + c.SetCookie("access_token", accessString, int(time.Until(accessTokenExp).Seconds()), + "/", "", secure, true) + c.SetCookie("refresh_token", refreshString, int(time.Until(refreshTokenExp).Seconds()), + "/", "", secure, true) + + aH.logger.Info("Login", "user "+user.Name+" logged in successfully") + + c.JSON(http.StatusOK, gin.H{ + "message": "login successful", + "id": user.Id, + "user": user.Name, + "role": user.Role, + "settings": user.Settings, + }) +} + +// Refresh generates a new access token from a valid refresh token. +func (aH *AccessHandlerAPI) Refresh(c *gin.Context) { + aH.logger.Debug("Refresh", "get refresh cookie") + + refreshCookie, err := c.Cookie("refresh_token") + if err != nil { + aH.logger.Error("Refresh", "no refresh token") + c.JSON(http.StatusUnauthorized, gin.H{"message": "no refresh token"}) + return + } + + // Validate token + aH.logger.Debug("Refresh", "parse token") + token, err := jwt.Parse(refreshCookie, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return REFRESH_SECRET, nil + }) + if err != nil || !token.Valid { + aH.logger.Error("Refresh", err) + c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid refresh token"}) + return + } + + // Extract claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || claims["type"] != "refresh" { + aH.logger.Error("Refresh", "invalid token type") + c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid token type"}) + return + } + + username := claims["username"].(string) + id := int(claims["id"].(float64)) + role := claims["role"].(string) + + // Create new access token + aH.logger.Debug("Refresh", "create new access token") + accessExp := time.Now().Add(ACCESS_TOKEN_TIME) + newAccess := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "id": id, + "username": username, + "role": role, + "exp": accessExp.Unix(), + }) + accessString, _ := newAccess.SignedString(ACCESS_SECRET) + + // Set new access cookie + aH.logger.Debug("Refresh", "set new access cookie") + c.SetCookie("access_token", accessString, int(time.Until(accessExp).Seconds()), + "/", DOMAIN, gin.Mode() == gin.ReleaseMode, true) + + c.JSON(http.StatusOK, gin.H{"message": "token refreshed"}) +} + +// Me returns information about the currently authenticated user. +func (aH *AccessHandlerAPI) Me(c *gin.Context) { + aH.logger.Debug("Me", "get access cookie") + + cookie, err := c.Cookie("access_token") + if err != nil { + aH.logger.Error("Me", err) + c.JSON(http.StatusUnauthorized, gin.H{"message": "not logged in"}) + return + } + + // Parse and validate access token + aH.logger.Debug("Me", "parse token") + token, err := jwt.Parse(cookie, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return ACCESS_SECRET, nil + }) + if err != nil || !token.Valid { + aH.logger.Error("Me", err) + c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid token"}) + return + } + + claims := token.Claims.(jwt.MapClaims) + c.JSON(http.StatusOK, gin.H{ + "id": claims["id"], + "user": claims["username"], + "role": claims["role"], + }) +} + +// Logout clears authentication cookies and ends the session. +func (aH *AccessHandlerAPI) Logout(c *gin.Context) { + aH.logger.Info("Logout", "logout user") + + secure := gin.Mode() == gin.ReleaseMode + aH.logger.Debug("Logout", fmt.Sprintf("domain=%s secure=%t", DOMAIN, secure)) + + // Clear cookies + c.SetCookie("access_token", "", -1, "/", DOMAIN, secure, true) + c.SetCookie("refresh_token", "", -1, "/", DOMAIN, secure, true) + + c.JSON(http.StatusOK, gin.H{"message": "logged out"}) +}