279 lines
8.1 KiB
Go
279 lines
8.1 KiB
Go
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
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."})
|
|
}
|