// Vikunja is a to-do list application to facilitate your life. // Copyright 2018-present Vikunja and contributors. All rights reserved. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package v1 import ( "net/http" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/modules/auth/ldap" "code.vikunja.io/api/pkg/modules/keyvalue" user2 "code.vikunja.io/api/pkg/user" "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v5" ) // Login is the login handler // @Summary Login // @Description Logs a user in. Returns a JWT-Token to authenticate further requests. // @tags auth // @Accept json // @Produce json // @Param credentials body user.Login true "The login credentials" // @Success 200 {object} auth.Token // @Failure 400 {object} models.Message "Invalid user password model." // @Failure 412 {object} models.Message "Invalid totp passcode." // @Failure 403 {object} models.Message "Invalid username or password." // @Router /login [post] func Login(c *echo.Context) (err error) { u := user2.Login{} if err := c.Bind(&u); err != nil { return c.JSON(http.StatusBadRequest, models.Message{Message: "Please provide a username and password."}) } s := db.NewSession() defer s.Close() // Discards events queued during a rolled-back transaction (e.g. LDAP user // creation); a no-op once DispatchPending has run. defer events.CleanupPending(s) var user *user2.User if config.AuthLdapEnabled.GetBool() { user, err = ldap.AuthenticateUserInLDAP(s, u.Username, u.Password, config.AuthLdapGroupSyncEnabled.GetBool(), config.AuthLdapAvatarSyncAttribute.GetString()) if err != nil && !user2.IsErrWrongUsernameOrPassword(err) { _ = s.Rollback() return err } } if user == nil { // Check if the user is a bot before attempting password verification, // because bots have no password hash and bcrypt would fail with a // misleading error. existingUser, lookupErr := user2.GetUserByUsername(s, u.Username) if lookupErr == nil && existingUser.IsBot() { _ = s.Rollback() return &user2.ErrAccountIsBot{UserID: existingUser.ID} } // This allows us to still have local users while ldap is enabled user, err = user2.CheckUserCredentials(s, &u) if err != nil { _ = s.Rollback() return err } } if user.Status == user2.StatusDisabled || user.Status == user2.StatusAccountLocked { _ = s.Rollback() return &user2.ErrAccountDisabled{UserID: user.ID} } totpEnabled, err := user2.TOTPEnabledForUser(s, user) if err != nil { _ = s.Rollback() return err } if totpEnabled { if u.TOTPPasscode == "" { _ = s.Rollback() return user2.ErrInvalidTOTPPasscode{} } _, err = user2.ValidateTOTPPasscode(s, &user2.TOTPPasscode{ User: user, Passcode: u.TOTPPasscode, }) if err != nil { // Rollback before HandleFailedTOTPAuth so its dedicated session // can acquire a write lock on SQLite shared-cache. The lockout // write is decoupled from this handler's transaction — see // GHSA-fgfv-pv97-6cmj. _ = s.Rollback() if user2.IsErrInvalidTOTPPasscode(err) { user2.HandleFailedTOTPAuth(user) } return err } } if err := keyvalue.Del(user.GetFailedTOTPAttemptsKey()); err != nil { return err } if err := keyvalue.Del(user.GetFailedPasswordAttemptsKey()); err != nil { return err } if err := s.Commit(); err != nil { _ = s.Rollback() return err } events.DispatchPending(c.Request().Context(), s) // Create token return auth.NewUserAuthTokenResponse(user, c, u.LongToken) } // RenewToken renews a link share token only. User tokens must use // POST /user/token/refresh with a refresh token instead. // @Summary Renew link share token // @Description Returns a new valid jwt link share token. Only works for link share tokens. // @tags auth // @Accept json // @Produce json // @Success 200 {object} auth.Token // @Failure 400 {object} models.Message "Only link share tokens can be renewed." // @Router /user/token [post] func RenewToken(c *echo.Context) (err error) { jwtinf := c.Get("user").(*jwt.Token) claims := jwtinf.Claims.(jwt.MapClaims) typFloat, is := claims["type"].(float64) if !is { return echo.NewHTTPError(http.StatusBadRequest, "Invalid JWT token.") } typ := int(typFloat) if typ == auth.AuthTypeUser { return echo.NewHTTPError( http.StatusBadRequest, "User tokens cannot be renewed via this endpoint. Use POST /user/token/refresh with a refresh token.", ) } if typ != auth.AuthTypeLinkShare { return echo.NewHTTPError(http.StatusBadRequest, "Invalid token type.") } s := db.NewSession() defer s.Close() share := &models.LinkSharing{} idFloat, is := claims["id"].(float64) if !is { return echo.NewHTTPError(http.StatusBadRequest, "Invalid JWT token.") } share.ID = int64(idFloat) err = share.ReadOne(s, share) if err != nil { _ = s.Rollback() return err } t, err := auth.NewLinkShareJWTAuthtoken(share) if err != nil { _ = s.Rollback() return err } if err := s.Commit(); err != nil { _ = s.Rollback() return err } return c.JSON(http.StatusOK, auth.Token{Token: t}) } // RefreshToken exchanges a valid refresh token (sent as an HttpOnly cookie) for // a new short-lived JWT. The refresh token is rotated on every call. // @Summary Refresh user token // @Description Exchanges the refresh token cookie for a new short-lived JWT. // @tags auth // @Produce json // @Success 200 {object} auth.Token // @Failure 401 {object} models.Message "Invalid or expired refresh token." // @Router /user/token/refresh [post] func RefreshToken(c *echo.Context) (err error) { cookie, err := c.Cookie(auth.RefreshTokenCookieName) if err != nil || cookie.Value == "" { return echo.NewHTTPError(http.StatusUnauthorized, "No refresh token provided.") } result, err := auth.RefreshSession(cookie.Value) if err != nil { if user2.IsErrUserStatusError(err) { auth.ClearRefreshTokenCookie(c) } return err } cookieMaxAge := int(config.ServiceJWTTTL.GetInt64()) if result.IsLongSession { cookieMaxAge = int(config.ServiceJWTTTLLong.GetInt64()) } auth.SetRefreshTokenCookie(c, result.NewRefreshToken, cookieMaxAge) c.Response().Header().Set("Cache-Control", "no-store") return c.JSON(http.StatusOK, auth.Token{Token: result.AccessToken}) } // Logout deletes the current session from the server. // @Summary Logout // @Description Destroys the current session and clears the refresh token cookie. // @tags auth // @Produce json // @Success 200 {object} models.Message "Successfully logged out." // @Router /user/logout [post] func Logout(c *echo.Context) (err error) { auth.ClearRefreshTokenCookie(c) var sid string var userID int64 if raw := c.Get("user"); raw != nil { if jwtinf, ok := raw.(*jwt.Token); ok { if claims, ok := jwtinf.Claims.(jwt.MapClaims); ok { sid, _ = claims["sid"].(string) if id, ok := claims["id"].(float64); ok { userID = int64(id) } } } } if sid == "" { return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."}) } s := db.NewSession() defer s.Close() _, err = s.Where("id = ?", sid).Delete(&models.Session{}) if err != nil { _ = s.Rollback() return err } if err := s.Commit(); err != nil { _ = s.Rollback() return err } if userID != 0 { if err := events.DispatchWithContext(c.Request().Context(), &user2.LogoutEvent{UserID: userID}); err != nil { log.Errorf("Could not dispatch logout event: %s", err) } } return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."}) }