diff --git a/pkg/modules/auth/oauth2server/authorize.go b/pkg/modules/auth/oauth2server/authorize.go index 873c00900..96afbbad7 100644 --- a/pkg/modules/auth/oauth2server/authorize.go +++ b/pkg/modules/auth/oauth2server/authorize.go @@ -26,8 +26,8 @@ import ( "github.com/labstack/echo/v5" ) -// authorizeRequest represents the JSON body for the authorize endpoint. -type authorizeRequest struct { +// AuthorizeRequest represents the body for the authorize endpoint. +type AuthorizeRequest struct { ResponseType string `json:"response_type"` ClientID string `json:"client_id"` RedirectURI string `json:"redirect_uri"` @@ -47,54 +47,66 @@ type AuthorizeResponse struct { // It validates the OAuth parameters, creates an authorization code, and // returns it as JSON. Authentication is handled by the token middleware. func HandleAuthorize(c *echo.Context) error { - var req authorizeRequest + var req AuthorizeRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") } - // Validate response_type - if req.ResponseType != "code" { - return echo.NewHTTPError(http.StatusBadRequest, "response_type must be 'code'") - } - - // Validate redirect_uri - if !ValidateRedirectURI(req.RedirectURI) { - return &models.ErrOAuthInvalidRedirectURI{} - } - - // Validate PKCE (required) - if req.CodeChallenge == "" || req.CodeChallengeMethod != "S256" { - return &models.ErrOAuthMissingPKCE{} - } - // Get the authenticated user from the middleware u, err := user.GetCurrentUser(c) if err != nil { return err } + resp, err := Authorize(&req, u.ID) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, resp) +} + +// Authorize validates the OAuth authorization parameters for the given +// authenticated user and creates a single-use authorization code, independent +// of the HTTP layer. Callers own request binding and resolving the user. +func Authorize(req *AuthorizeRequest, userID int64) (*AuthorizeResponse, error) { + // Validate response_type + if req.ResponseType != "code" { + return nil, echo.NewHTTPError(http.StatusBadRequest, "response_type must be 'code'") + } + + // Validate redirect_uri + if !ValidateRedirectURI(req.RedirectURI) { + return nil, &models.ErrOAuthInvalidRedirectURI{} + } + + // Validate PKCE (required) + if req.CodeChallenge == "" || req.CodeChallengeMethod != "S256" { + return nil, &models.ErrOAuthMissingPKCE{} + } + s := db.NewSession() defer s.Close() - fullUser, err := user.GetUserByID(s, u.ID) + fullUser, err := user.GetUserByID(s, userID) if err != nil { _ = s.Rollback() - return err + return nil, err } code, err := models.CreateOAuthCode(s, fullUser.ID, req.ClientID, req.RedirectURI, req.CodeChallenge, req.CodeChallengeMethod) if err != nil { _ = s.Rollback() - return err + return nil, err } if err := s.Commit(); err != nil { - return err + return nil, err } - return c.JSON(http.StatusOK, AuthorizeResponse{ + return &AuthorizeResponse{ Code: code, RedirectURI: req.RedirectURI, State: req.State, - }) + }, nil } diff --git a/pkg/modules/auth/oauth2server/token.go b/pkg/modules/auth/oauth2server/token.go index 2725b988d..9d8d33a9a 100644 --- a/pkg/modules/auth/oauth2server/token.go +++ b/pkg/modules/auth/oauth2server/token.go @@ -36,35 +36,51 @@ type TokenResponse struct { RefreshToken string `json:"refresh_token"` } -// tokenRequest holds the JSON body of a POST /oauth/token request. -type tokenRequest struct { - GrantType string `json:"grant_type"` - Code string `json:"code"` - ClientID string `json:"client_id"` - RedirectURI string `json:"redirect_uri"` - CodeVerifier string `json:"code_verifier"` - RefreshToken string `json:"refresh_token"` +// TokenRequest holds the parameters of a POST /oauth/token request. v1 binds it +// from JSON; v2 accepts spec-compliant application/x-www-form-urlencoded as well +// (form tags mirror the json names). +type TokenRequest struct { + GrantType string `json:"grant_type" form:"grant_type"` + Code string `json:"code" form:"code"` + ClientID string `json:"client_id" form:"client_id"` + RedirectURI string `json:"redirect_uri" form:"redirect_uri"` + CodeVerifier string `json:"code_verifier" form:"code_verifier"` + RefreshToken string `json:"refresh_token" form:"refresh_token"` } // HandleToken handles POST /oauth/token. // Supports grant_type=authorization_code and grant_type=refresh_token. func HandleToken(c *echo.Context) error { - var req tokenRequest + var req TokenRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") } + resp, err := ExchangeToken(&req, c.Request().UserAgent(), c.RealIP()) + if err != nil { + return err + } + + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, resp) +} + +// ExchangeToken runs the grant-type dispatch and token issuance for the OAuth +// token endpoint, independent of the HTTP layer. Callers own request binding and +// the Cache-Control: no-store response header. deviceInfo/ipAddress are recorded +// on the session created for the authorization_code grant. +func ExchangeToken(req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { switch req.GrantType { case "authorization_code": - return handleAuthorizationCodeGrant(c, &req) + return exchangeAuthorizationCode(req, deviceInfo, ipAddress) case "refresh_token": - return handleRefreshTokenGrant(c, &req) + return exchangeRefreshToken(req) default: - return &models.ErrOAuthInvalidGrantType{} + return nil, &models.ErrOAuthInvalidGrantType{} } } -func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error { +func exchangeAuthorizationCode(req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { s := db.NewSession() defer s.Close() @@ -72,73 +88,69 @@ func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error { oauthCode, err := models.GetAndDeleteOAuthCode(s, req.Code) if err != nil { _ = s.Rollback() - return err + return nil, err } // Validate client_id matches if oauthCode.ClientID != req.ClientID { _ = s.Rollback() - return &models.ErrOAuthClientNotFound{} + return nil, &models.ErrOAuthClientNotFound{} } // Validate redirect_uri matches if oauthCode.RedirectURI != req.RedirectURI { _ = s.Rollback() - return &models.ErrOAuthInvalidRedirectURI{} + return nil, &models.ErrOAuthInvalidRedirectURI{} } // Verify PKCE if !VerifyPKCE(req.CodeVerifier, oauthCode.CodeChallenge, oauthCode.CodeChallengeMethod) { _ = s.Rollback() - return &models.ErrOAuthPKCEVerifyFailed{} + return nil, &models.ErrOAuthPKCEVerifyFailed{} } // Create a session (reuses existing session infrastructure) - deviceInfo := c.Request().UserAgent() - ipAddress := c.RealIP() session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false) if err != nil { _ = s.Rollback() - return err + return nil, err } u, err := user.GetUserByID(s, oauthCode.UserID) if err != nil { _ = s.Rollback() - return err + return nil, err } // Generate JWT accessToken, err := auth.NewUserJWTAuthtoken(u, session.ID) if err != nil { _ = s.Rollback() - return err + return nil, err } if err := s.Commit(); err != nil { - return err + return nil, err } - c.Response().Header().Set("Cache-Control", "no-store") - return c.JSON(http.StatusOK, TokenResponse{ + return &TokenResponse{ AccessToken: accessToken, TokenType: "bearer", ExpiresIn: config.ServiceJWTTTLShort.GetInt64(), RefreshToken: session.RefreshToken, - }) + }, nil } -func handleRefreshTokenGrant(c *echo.Context, req *tokenRequest) error { +func exchangeRefreshToken(req *TokenRequest) (*TokenResponse, error) { result, err := auth.RefreshSession(req.RefreshToken) if err != nil { - return err + return nil, err } - c.Response().Header().Set("Cache-Control", "no-store") - return c.JSON(http.StatusOK, TokenResponse{ + return &TokenResponse{ AccessToken: result.AccessToken, TokenType: "bearer", ExpiresIn: result.ExpiresIn, RefreshToken: result.NewRefreshToken, - }) + }, nil } diff --git a/pkg/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go new file mode 100644 index 000000000..d11a1cdbb --- /dev/null +++ b/pkg/routes/api/shared/auth.go @@ -0,0 +1,171 @@ +// 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 shared + +import ( + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/metrics" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/user" +) + +// UserRegister carries the fields accepted by the public registration endpoint: +// username, password and email (from APIUserPassword) plus the new user's +// preferred language. +type UserRegister struct { + // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja. + Language string `json:"language" valid:"language" doc:"The language of the new user as an IETF BCP 47 code (e.g. en, de-DE)."` + user.APIUserPassword +} + +// RegisterUser creates a new local user account from the registration input and +// busts the cached user-count metric so the registration shows up immediately. +// The caller is responsible for the registration-enabled gate and input +// validation; both v1 and v2 share this body. +func RegisterUser(in *UserRegister) (*user.User, error) { + s := db.NewSession() + defer s.Close() + + newUser, err := models.RegisterUser(s, &user.User{ + Username: in.Username, + Password: in.Password, + Email: in.Email, + Language: in.Language, + }) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, err + } + + // Bust the cached user count so the new registration shows up in metrics + // immediately instead of after the regular cache expiry. + if config.MetricsEnabled.GetBool() { + if err := metrics.InvalidateCount(metrics.UserCountKey); err != nil { + log.Errorf("Could not invalidate user count metric: %s", err) + } + } + + return newUser, nil +} + +// ResetPassword resets a user's password from a previously issued reset token +// and invalidates all of that user's sessions, so a leaked password cannot be +// used after a reset. Shared by v1 and v2. +func ResetPassword(reset *user.PasswordReset) error { + s := db.NewSession() + defer s.Close() + + userID, err := user.ResetPassword(s, reset) + if err != nil { + _ = s.Rollback() + return err + } + + if err := models.DeleteAllUserSessions(s, userID); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + +// RequestPasswordResetToken issues a password-reset token for the account with +// the given email and sends it via email. Shared by v1 and v2. +func RequestPasswordResetToken(req *user.PasswordTokenRequest) error { + s := db.NewSession() + defer s.Close() + + if err := user.RequestUserPasswordResetTokenByEmail(s, req); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + +// ConfirmEmail confirms a newly registered user's email from the token sent to +// them. Shared by v1 and v2. +func ConfirmEmail(confirm *user.EmailConfirm) error { + s := db.NewSession() + defer s.Close() + + if err := user.ConfirmEmail(s, confirm); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + +// LinkShareToken is the response for the link-share auth endpoint. It embeds the +// authenticated share alongside the issued JWT and re-exposes the project id +// (which LinkSharing hides with json:"-"). The embedded share's write-only +// Password is blanked by AuthenticateLinkShare before this is returned. +type LinkShareToken struct { + auth.Token + *models.LinkSharing + ProjectID int64 `json:"project_id" readOnly:"true" doc:"The id of the project this share grants access to."` +} + +// AuthenticateLinkShare resolves a link share by its public hash, verifies the +// password for password-protected shares, and issues a JWT auth token for it. +// The returned token's embedded share has its password blanked. Shared by v1 +// and v2. +func AuthenticateLinkShare(hash, password string) (*LinkShareToken, error) { + s := db.NewSession() + defer s.Close() + + share, err := models.GetLinkShareByHash(s, hash) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if share.SharingType == models.SharingTypeWithPassword { + if err := models.VerifyLinkSharePassword(share, password); err != nil { + _ = s.Rollback() + return nil, err + } + } + + t, err := auth.NewLinkShareJWTAuthtoken(share) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, err + } + + share.Password = "" + + return &LinkShareToken{ + Token: auth.Token{Token: t}, + LinkSharing: share, + ProjectID: share.ProjectID, + }, nil +} diff --git a/pkg/routes/api/v1/link_sharing_auth.go b/pkg/routes/api/v1/link_sharing_auth.go index 9e20a94f8..f4ca79ed0 100644 --- a/pkg/routes/api/v1/link_sharing_auth.go +++ b/pkg/routes/api/v1/link_sharing_auth.go @@ -19,20 +19,11 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/routes/api/shared" - "code.vikunja.io/api/pkg/models" - "code.vikunja.io/api/pkg/modules/auth" "github.com/labstack/echo/v5" ) -// LinkShareToken represents a link share auth token with extra infos about the actual link share -type LinkShareToken struct { - auth.Token - *models.LinkSharing - ProjectID int64 `json:"project_id"` -} - // LinkShareAuth represents everything required to authenticate a link share type LinkShareAuth struct { Hash string `param:"share" json:"-"` @@ -53,36 +44,14 @@ type LinkShareAuth struct { // @Router /shares/{share}/auth [post] func AuthenticateLinkShare(c *echo.Context) error { sh := &LinkShareAuth{} - err := c.Bind(sh) + if err := c.Bind(sh); err != nil { + return err + } + + token, err := shared.AuthenticateLinkShare(sh.Hash, sh.Password) if err != nil { return err } - s := db.NewSession() - defer s.Close() - - share, err := models.GetLinkShareByHash(s, sh.Hash) - if err != nil { - return err - } - - if share.SharingType == models.SharingTypeWithPassword { - err := models.VerifyLinkSharePassword(share, sh.Password) - if err != nil { - return err - } - } - - t, err := auth.NewLinkShareJWTAuthtoken(share) - if err != nil { - return err - } - - share.Password = "" - - return c.JSON(http.StatusOK, LinkShareToken{ - Token: auth.Token{Token: t}, - LinkSharing: share, - ProjectID: share.ProjectID, - }) + return c.JSON(http.StatusOK, token) } diff --git a/pkg/routes/api/v1/user_confirm_email.go b/pkg/routes/api/v1/user_confirm_email.go index e01865103..254d4142a 100644 --- a/pkg/routes/api/v1/user_confirm_email.go +++ b/pkg/routes/api/v1/user_confirm_email.go @@ -19,9 +19,8 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" "github.com/labstack/echo/v5" ) @@ -44,17 +43,7 @@ func UserConfirmEmail(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "No token provided.").Wrap(err) } - s := db.NewSession() - defer s.Close() - - err := user.ConfirmEmail(s, &emailConfirm) - if err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.ConfirmEmail(&emailConfirm); err != nil { return err } diff --git a/pkg/routes/api/v1/user_password_reset.go b/pkg/routes/api/v1/user_password_reset.go index b91a28a7a..6c8090ba0 100644 --- a/pkg/routes/api/v1/user_password_reset.go +++ b/pkg/routes/api/v1/user_password_reset.go @@ -19,9 +19,8 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" "github.com/labstack/echo/v5" ) @@ -49,22 +48,7 @@ func UserResetPassword(c *echo.Context) error { return err } - s := db.NewSession() - defer s.Close() - - userID, err := user.ResetPassword(s, &pwReset) - if err != nil { - _ = s.Rollback() - return err - } - - if err := models.DeleteAllUserSessions(s, userID); err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.ResetPassword(&pwReset); err != nil { return err } @@ -93,17 +77,7 @@ func UserRequestResetPasswordToken(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()).Wrap(err) } - s := db.NewSession() - defer s.Close() - - err := user.RequestUserPasswordResetTokenByEmail(s, &pwTokenReset) - if err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.RequestPasswordResetToken(&pwTokenReset); err != nil { return err } diff --git a/pkg/routes/api/v1/user_register.go b/pkg/routes/api/v1/user_register.go index 9db52c88a..1c70df765 100644 --- a/pkg/routes/api/v1/user_register.go +++ b/pkg/routes/api/v1/user_register.go @@ -21,20 +21,15 @@ import ( "net/http" "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/log" - "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/models" - "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/routes/api/shared" "github.com/labstack/echo/v5" ) -type UserRegister struct { - // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja. - Language string `json:"language" valid:"language"` - user.APIUserPassword -} +// UserRegister is an alias for the shared registration input, kept so the v1 +// swagger annotation and any existing imports still resolve. +type UserRegister = shared.UserRegister // RegisterUser is the register handler // @Summary Register @@ -68,32 +63,10 @@ func RegisterUser(c *echo.Context) error { return c.JSON(http.StatusBadRequest, models.Message{Message: "No or invalid user model provided."}) } - s := db.NewSession() - defer s.Close() - - newUser, err := models.RegisterUser(s, &user.User{ - Username: userIn.Username, - Password: userIn.Password, - Email: userIn.Email, - Language: userIn.Language, - }) + newUser, err := shared.RegisterUser(userIn) if err != nil { - _ = s.Rollback() return err } - if err := s.Commit(); err != nil { - _ = s.Rollback() - return err - } - - // Bust the cached user count so the new registration shows up in metrics - // immediately instead of after the regular cache expiry. - if config.MetricsEnabled.GetBool() { - if err := metrics.InvalidateCount(metrics.UserCountKey); err != nil { - log.Errorf("Could not invalidate user count metric: %s", err) - } - } - return c.JSON(http.StatusOK, newUser) }