mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-10-29 01:52:26 -05:00
[feature] add TOTP two-factor authentication (2FA) (#3960)
* [feature] add TOTP two-factor authentication (2FA) * use byteutil.S2B to avoid allocations when comparing + generating password hashes * don't bother with string conversion for consts * use io.ReadFull * use MustGenerateSecret for backup codes * rename util functions
This commit is contained in:
parent
6f24205a26
commit
365b575341
78 changed files with 5593 additions and 825 deletions
|
|
@ -20,12 +20,12 @@ package api
|
|||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/auth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/middleware"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oidc"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
)
|
||||
|
||||
type Auth struct {
|
||||
|
|
@ -55,13 +55,19 @@ func (a *Auth) Route(r *router.Router, m ...gin.HandlerFunc) {
|
|||
oauthGroup.Use(ccMiddleware, sessionMiddleware)
|
||||
|
||||
a.auth.RouteAuth(authGroup.Handle)
|
||||
a.auth.RouteOauth(oauthGroup.Handle)
|
||||
a.auth.RouteOAuth(oauthGroup.Handle)
|
||||
}
|
||||
|
||||
func NewAuth(db db.DB, p *processing.Processor, idp oidc.IDP, routerSession *gtsmodel.RouterSession, sessionName string) *Auth {
|
||||
func NewAuth(
|
||||
state *state.State,
|
||||
p *processing.Processor,
|
||||
idp oidc.IDP,
|
||||
routerSession *gtsmodel.RouterSession,
|
||||
sessionName string,
|
||||
) *Auth {
|
||||
return &Auth{
|
||||
routerSession: routerSession,
|
||||
sessionName: sessionName,
|
||||
auth: auth.New(db, p, idp),
|
||||
auth: auth.New(state, p, idp),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,10 @@ package auth
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oidc"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -32,61 +31,58 @@ const (
|
|||
paths prefixed with 'auth'
|
||||
*/
|
||||
|
||||
// AuthSignInPath is the API path for users to sign in through
|
||||
AuthSignInPath = "/sign_in"
|
||||
// AuthCheckYourEmailPath users land here after registering a new account, instructs them to confirm their email
|
||||
AuthCheckYourEmailPath = "/check_your_email"
|
||||
// AuthWaitForApprovalPath users land here after confirming their email
|
||||
// but before an admin approves their account (if such is required)
|
||||
AuthSignInPath = "/sign_in"
|
||||
Auth2FAPath = "/2fa"
|
||||
AuthCheckYourEmailPath = "/check_your_email"
|
||||
AuthWaitForApprovalPath = "/wait_for_approval"
|
||||
// AuthAccountDisabledPath users land here when their account is suspended by an admin
|
||||
AuthAccountDisabledPath = "/account_disabled"
|
||||
// AuthCallbackPath is the API path for receiving callback tokens from external OIDC providers
|
||||
AuthCallbackPath = "/callback"
|
||||
AuthCallbackPath = "/callback"
|
||||
|
||||
/*
|
||||
paths prefixed with 'oauth'
|
||||
*/
|
||||
|
||||
// OauthTokenPath is the API path to use for granting token requests to users with valid credentials
|
||||
OauthTokenPath = "/token" // #nosec G101 else we get a hardcoded credentials warning
|
||||
// OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user)
|
||||
OauthAuthorizePath = "/authorize"
|
||||
// OauthFinalizePath is the API path for completing user registration with additional user details
|
||||
OauthFinalizePath = "/finalize"
|
||||
// OauthOobTokenPath is the path for serving an html representation of an oob token page.
|
||||
OauthOobTokenPath = "/oob" // #nosec G101 else we get a hardcoded credentials warning
|
||||
OauthFinalizePath = "/finalize"
|
||||
OauthOOBTokenPath = "/oob" // #nosec G101 else we get a hardcoded credentials warning
|
||||
OauthTokenPath = "/token" // #nosec G101 else we get a hardcoded credentials warning
|
||||
|
||||
/*
|
||||
params / session keys
|
||||
*/
|
||||
|
||||
callbackStateParam = "state"
|
||||
callbackCodeParam = "code"
|
||||
sessionUserID = "userid"
|
||||
sessionClientID = "client_id"
|
||||
sessionRedirectURI = "redirect_uri"
|
||||
sessionForceLogin = "force_login"
|
||||
sessionResponseType = "response_type"
|
||||
sessionScope = "scope"
|
||||
sessionInternalState = "internal_state"
|
||||
sessionClientState = "client_state"
|
||||
sessionClaims = "claims"
|
||||
sessionAppID = "app_id"
|
||||
callbackStateParam = "state"
|
||||
callbackCodeParam = "code"
|
||||
sessionUserID = "userid"
|
||||
sessionUserIDAwaiting2FA = "userid_awaiting_2fa"
|
||||
sessionClientID = "client_id"
|
||||
sessionRedirectURI = "redirect_uri"
|
||||
sessionForceLogin = "force_login"
|
||||
sessionResponseType = "response_type"
|
||||
sessionScope = "scope"
|
||||
sessionInternalState = "internal_state"
|
||||
sessionClientState = "client_state"
|
||||
sessionClaims = "claims"
|
||||
sessionAppID = "app_id"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
db db.DB
|
||||
state *state.State
|
||||
processor *processing.Processor
|
||||
idp oidc.IDP
|
||||
}
|
||||
|
||||
// New returns an Auth module which provides both 'oauth' and 'auth' endpoints.
|
||||
// New returns an Auth module which provides
|
||||
// both 'oauth' and 'auth' endpoints.
|
||||
//
|
||||
// It is safe to pass a nil idp if oidc is disabled.
|
||||
func New(db db.DB, processor *processing.Processor, idp oidc.IDP) *Module {
|
||||
func New(
|
||||
state *state.State,
|
||||
processor *processing.Processor,
|
||||
idp oidc.IDP,
|
||||
) *Module {
|
||||
return &Module{
|
||||
db: db,
|
||||
state: state,
|
||||
processor: processor,
|
||||
idp: idp,
|
||||
}
|
||||
|
|
@ -96,21 +92,16 @@ func New(db db.DB, processor *processing.Processor, idp oidc.IDP) *Module {
|
|||
func (m *Module) RouteAuth(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
|
||||
attachHandler(http.MethodGet, AuthSignInPath, m.SignInGETHandler)
|
||||
attachHandler(http.MethodPost, AuthSignInPath, m.SignInPOSTHandler)
|
||||
attachHandler(http.MethodGet, Auth2FAPath, m.TwoFactorCodeGETHandler)
|
||||
attachHandler(http.MethodPost, Auth2FAPath, m.TwoFactorCodePOSTHandler)
|
||||
attachHandler(http.MethodGet, AuthCallbackPath, m.CallbackGETHandler)
|
||||
}
|
||||
|
||||
// RouteOauth routes all paths that should have an 'oauth' prefix
|
||||
func (m *Module) RouteOauth(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
|
||||
// RouteOAuth routes all paths that should have an 'oauth' prefix
|
||||
func (m *Module) RouteOAuth(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
|
||||
attachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler)
|
||||
attachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler)
|
||||
attachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler)
|
||||
attachHandler(http.MethodPost, OauthFinalizePath, m.FinalizePOSTHandler)
|
||||
attachHandler(http.MethodGet, OauthOobTokenPath, m.OobHandler)
|
||||
}
|
||||
|
||||
func (m *Module) clearSession(s sessions.Session) {
|
||||
s.Clear()
|
||||
if err := s.Save(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
attachHandler(http.MethodGet, OauthOOBTokenPath, m.OOBTokenGETHandler)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ func (suite *AuthStandardTestSuite) SetupTest() {
|
|||
testrig.NewNoopWebPushSender(),
|
||||
suite.mediaManager,
|
||||
)
|
||||
suite.authModule = auth.New(suite.db, suite.processor, suite.idp)
|
||||
suite.authModule = auth.New(&suite.state, suite.processor, suite.idp)
|
||||
|
||||
testrig.StandardDBSetup(suite.db, suite.testAccounts)
|
||||
testrig.StartNoopWorkers(&suite.state)
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
|
|
@ -28,280 +26,227 @@ import (
|
|||
"github.com/google/uuid"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize
|
||||
// The idea here is to present an oauth authorize page to the user, with a button
|
||||
// that they have to click to accept.
|
||||
// AuthorizeGETHandler should be served as
|
||||
// GET at https://example.org/oauth/authorize.
|
||||
//
|
||||
// The idea here is to present an authorization
|
||||
// page to the user, informing them of the scopes
|
||||
// the application is requesting, with a button
|
||||
// that they have to click to give it permission.
|
||||
func (m *Module) AuthorizeGETHandler(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.HTMLAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
// UserID will be set in the session by AuthorizePOSTHandler if the caller has already gone through the authentication flow
|
||||
// If it's not set, then we don't know yet who the user is, so we need to redirect them to the sign in page.
|
||||
userID, ok := s.Get(sessionUserID).(string)
|
||||
if !ok || userID == "" {
|
||||
form := &apimodel.OAuthAuthorize{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
m.clearSession(s)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
s := sessions.Default(c)
|
||||
|
||||
if errWithCode := saveAuthFormToSession(s, form); errWithCode != nil {
|
||||
m.clearSession(s)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusSeeOther, "/auth"+AuthSignInPath)
|
||||
// UserID will be set in the session by
|
||||
// AuthorizePOSTHandler if the caller has
|
||||
// already gone through the auth flow.
|
||||
//
|
||||
// If it's not set, then we don't yet know
|
||||
// yet who the user is, so send them to the
|
||||
// sign in page first.
|
||||
if userID, ok := s.Get(sessionUserID).(string); !ok || userID == "" {
|
||||
m.redirectAuthFormToSignIn(c)
|
||||
return
|
||||
}
|
||||
|
||||
// use session information to validate app, user, and account for this request
|
||||
clientID, ok := s.Get(sessionClientID).(string)
|
||||
if !ok || clientID == "" {
|
||||
m.clearSession(s)
|
||||
err := fmt.Errorf("key %s was not found in session", sessionClientID)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
user := m.mustUserFromSession(c, s)
|
||||
if user == nil {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
app, err := m.db.GetApplicationByClientID(c.Request.Context(), clientID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.db.GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("user with id %s could not be retrieved", userID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if ensureUserIsAuthorizedOrRedirect(c, user, acct) {
|
||||
return
|
||||
}
|
||||
|
||||
// Finally we should also get the redirect and scope of this particular request, as stored in the session.
|
||||
redirect, ok := s.Get(sessionRedirectURI).(string)
|
||||
if !ok || redirect == "" {
|
||||
m.clearSession(s)
|
||||
err := fmt.Errorf("key %s was not found in session", sessionRedirectURI)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
scope, ok := s.Get(sessionScope).(string)
|
||||
if !ok || scope == "" {
|
||||
m.clearSession(s)
|
||||
err := fmt.Errorf("key %s was not found in session", sessionScope)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
// If the user is unconfirmed, waiting approval,
|
||||
// or suspended, redirect to an appropriate help page.
|
||||
if !m.validateUser(c, user) {
|
||||
// Already
|
||||
// redirected.
|
||||
return
|
||||
}
|
||||
|
||||
// Everything looks OK.
|
||||
// Start preparing to render the html template.
|
||||
instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURI := m.mustStringFromSession(c, s, sessionRedirectURI)
|
||||
if redirectURI == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
scope := m.mustStringFromSession(c, s, sessionScope)
|
||||
if scope == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
app := m.mustAppFromSession(c, s)
|
||||
if app == nil {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
// The authorize template will display a form
|
||||
// to the user where they can see some info
|
||||
// about the app that's trying to authorize,
|
||||
// and the scope of the request. They can then
|
||||
// approve it if it looks OK to them, which
|
||||
// will POST to the AuthorizePOSTHandler.
|
||||
page := apiutil.WebPage{
|
||||
apiutil.TemplateWebPage(c, apiutil.WebPage{
|
||||
Template: "authorize.tmpl",
|
||||
Instance: instance,
|
||||
Extra: map[string]any{
|
||||
"appname": app.Name,
|
||||
"appwebsite": app.Website,
|
||||
"redirect": redirect,
|
||||
"redirect": redirectURI,
|
||||
"scope": scope,
|
||||
"user": acct.Username,
|
||||
"user": user.Account.Username,
|
||||
},
|
||||
}
|
||||
|
||||
apiutil.TemplateWebPage(c, page)
|
||||
})
|
||||
}
|
||||
|
||||
// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize
|
||||
// At this point we assume that the user has A) logged in and B) accepted that the app should act for them,
|
||||
// so we should proceed with the authentication flow and generate an oauth token for them if we can.
|
||||
// AuthorizePOSTHandler should be served as
|
||||
// POST at https://example.org/oauth/authorize.
|
||||
//
|
||||
// At this point we assume that the user has signed
|
||||
// in and permitted the app to act on their behalf.
|
||||
// We should proceed with the authentication flow
|
||||
// and generate an oauth code at the redirect URI.
|
||||
func (m *Module) AuthorizePOSTHandler(c *gin.Context) {
|
||||
|
||||
// We need to use the session cookie to
|
||||
// recreate the original form submitted
|
||||
// to the authorizeGEThandler so that it
|
||||
// can be validated by the oauth2 library.
|
||||
s := sessions.Default(c)
|
||||
|
||||
// We need to retrieve the original form submitted to the authorizeGEThandler, and
|
||||
// recreate it on the request so that it can be used further by the oauth2 library.
|
||||
errs := []string{}
|
||||
responseType := m.mustStringFromSession(c, s, sessionResponseType)
|
||||
if responseType == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
clientID := m.mustStringFromSession(c, s, sessionClientID)
|
||||
if clientID == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
redirectURI := m.mustStringFromSession(c, s, sessionRedirectURI)
|
||||
if redirectURI == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
scope := m.mustStringFromSession(c, s, sessionScope)
|
||||
if scope == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
user := m.mustUserFromSession(c, s)
|
||||
if user == nil {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
// Force login is optional with default of "false".
|
||||
forceLogin, ok := s.Get(sessionForceLogin).(string)
|
||||
if !ok {
|
||||
if !ok || forceLogin == "" {
|
||||
forceLogin = "false"
|
||||
}
|
||||
|
||||
responseType, ok := s.Get(sessionResponseType).(string)
|
||||
if !ok || responseType == "" {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionResponseType))
|
||||
}
|
||||
|
||||
clientID, ok := s.Get(sessionClientID).(string)
|
||||
if !ok || clientID == "" {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionClientID))
|
||||
}
|
||||
|
||||
redirectURI, ok := s.Get(sessionRedirectURI).(string)
|
||||
if !ok || redirectURI == "" {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionRedirectURI))
|
||||
}
|
||||
|
||||
scope, ok := s.Get(sessionScope).(string)
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionScope))
|
||||
}
|
||||
|
||||
// Client state is optional with default of "".
|
||||
var clientState string
|
||||
if s, ok := s.Get(sessionClientState).(string); ok {
|
||||
clientState = s
|
||||
if cs, ok := s.Get(sessionClientState).(string); ok {
|
||||
clientState = cs
|
||||
}
|
||||
|
||||
userID, ok := s.Get(sessionUserID).(string)
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionUserID))
|
||||
}
|
||||
|
||||
if len(errs) != 0 {
|
||||
errs = append(errs, oauth.HelpfulAdvice)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(errors.New("one or more missing keys on session during AuthorizePOSTHandler"), errs...), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.db.GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("user with id %s could not be retrieved", userID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if ensureUserIsAuthorizedOrRedirect(c, user, acct) {
|
||||
// If the user is unconfirmed, waiting approval,
|
||||
// or suspended, redirect to an appropriate help page.
|
||||
if !m.validateUser(c, user) {
|
||||
// Already
|
||||
// redirected.
|
||||
return
|
||||
}
|
||||
|
||||
// If we're redirecting to our OOB token handler,
|
||||
// we need to keep the session around so the OOB
|
||||
// handler can extract values from it. Otherwise,
|
||||
// we're going to be redirecting somewhere else
|
||||
// so we can safely clear the session now.
|
||||
if redirectURI != oauth.OOBURI {
|
||||
// we're done with the session now, so just clear it out
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
}
|
||||
|
||||
// we have to set the values on the request form
|
||||
// so that they're picked up by the oauth server
|
||||
// Set values on the request form so that
|
||||
// they're picked up by the oauth server.
|
||||
c.Request.Form = url.Values{
|
||||
sessionForceLogin: {forceLogin},
|
||||
sessionResponseType: {responseType},
|
||||
sessionClientID: {clientID},
|
||||
sessionRedirectURI: {redirectURI},
|
||||
sessionScope: {scope},
|
||||
sessionUserID: {userID},
|
||||
sessionUserID: {user.ID},
|
||||
sessionForceLogin: {forceLogin},
|
||||
}
|
||||
|
||||
if clientState != "" {
|
||||
// If client state was submitted,
|
||||
// set it on the form so it can be
|
||||
// fed back to the client via a query
|
||||
// param at the eventual redirect URL.
|
||||
c.Request.Form.Set("state", clientState)
|
||||
}
|
||||
|
||||
if errWithCode := m.processor.OAuthHandleAuthorizeRequest(c.Writer, c.Request); errWithCode != nil {
|
||||
// If OAuthHandleAuthorizeRequest is successful,
|
||||
// it'll handle any further redirects for us,
|
||||
// but we do still need to handle any errors.
|
||||
errWithCode := m.processor.OAuthHandleAuthorizeRequest(c.Writer, c.Request)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
}
|
||||
}
|
||||
|
||||
// saveAuthFormToSession checks the given OAuthAuthorize form,
|
||||
// and stores the values in the form into the session.
|
||||
func saveAuthFormToSession(s sessions.Session, form *apimodel.OAuthAuthorize) gtserror.WithCode {
|
||||
if form == nil {
|
||||
err := errors.New("OAuthAuthorize form was nil")
|
||||
return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice)
|
||||
// redirectAuthFormToSignIn binds an OAuthAuthorize form,
|
||||
// stores the values in the form into the session, and
|
||||
// redirects the user to the sign in page.
|
||||
func (m *Module) redirectAuthFormToSignIn(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
form := &apimodel.OAuthAuthorize{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
m.clearSessionWithBadRequest(c, s, err, err.Error(), oauth.HelpfulAdvice)
|
||||
return
|
||||
}
|
||||
|
||||
if form.ResponseType == "" {
|
||||
err := errors.New("field response_type was not set on OAuthAuthorize form")
|
||||
return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice)
|
||||
}
|
||||
|
||||
if form.ClientID == "" {
|
||||
err := errors.New("field client_id was not set on OAuthAuthorize form")
|
||||
return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice)
|
||||
}
|
||||
|
||||
if form.RedirectURI == "" {
|
||||
err := errors.New("field redirect_uri was not set on OAuthAuthorize form")
|
||||
return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice)
|
||||
}
|
||||
|
||||
// set default scope to read
|
||||
// Set default scope to read.
|
||||
if form.Scope == "" {
|
||||
form.Scope = "read"
|
||||
}
|
||||
|
||||
// save these values from the form so we can use them elsewhere in the session
|
||||
// Save these values from the form so we
|
||||
// can use them elsewhere in the session.
|
||||
s.Set(sessionForceLogin, form.ForceLogin)
|
||||
s.Set(sessionResponseType, form.ResponseType)
|
||||
s.Set(sessionClientID, form.ClientID)
|
||||
|
|
@ -310,32 +255,43 @@ func saveAuthFormToSession(s sessions.Session, form *apimodel.OAuthAuthorize) gt
|
|||
s.Set(sessionInternalState, uuid.NewString())
|
||||
s.Set(sessionClientState, form.State)
|
||||
|
||||
if err := s.Save(); err != nil {
|
||||
err := fmt.Errorf("error saving form values onto session: %s", err)
|
||||
return gtserror.NewErrorInternalError(err, oauth.HelpfulAdvice)
|
||||
}
|
||||
|
||||
return nil
|
||||
m.mustSaveSession(s)
|
||||
c.Redirect(http.StatusSeeOther, "/auth"+AuthSignInPath)
|
||||
}
|
||||
|
||||
func ensureUserIsAuthorizedOrRedirect(ctx *gin.Context, user *gtsmodel.User, account *gtsmodel.Account) (redirected bool) {
|
||||
if user.ConfirmedAt.IsZero() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/auth"+AuthCheckYourEmailPath)
|
||||
redirected = true
|
||||
return
|
||||
}
|
||||
// validateUser checks if the given user:
|
||||
//
|
||||
// 1. Has a confirmed email address.
|
||||
// 2. Has been approved.
|
||||
// 3. Is not disabled or suspended.
|
||||
//
|
||||
// If all looks OK, returns true. Otherwise,
|
||||
// redirects to a help page and returns false.
|
||||
func (m *Module) validateUser(
|
||||
c *gin.Context,
|
||||
user *gtsmodel.User,
|
||||
) bool {
|
||||
switch {
|
||||
case user.ConfirmedAt.IsZero():
|
||||
// User email not confirmed yet.
|
||||
const redirectTo = "/auth" + AuthCheckYourEmailPath
|
||||
c.Redirect(http.StatusSeeOther, redirectTo)
|
||||
return false
|
||||
|
||||
if !*user.Approved {
|
||||
ctx.Redirect(http.StatusSeeOther, "/auth"+AuthWaitForApprovalPath)
|
||||
redirected = true
|
||||
return
|
||||
}
|
||||
case !*user.Approved:
|
||||
// User signup not approved yet.
|
||||
const redirectTo = "/auth" + AuthWaitForApprovalPath
|
||||
c.Redirect(http.StatusSeeOther, redirectTo)
|
||||
return false
|
||||
|
||||
if *user.Disabled || !account.SuspendedAt.IsZero() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/auth"+AuthAccountDisabledPath)
|
||||
redirected = true
|
||||
return
|
||||
}
|
||||
case *user.Disabled || !user.Account.SuspendedAt.IsZero():
|
||||
// User disabled or suspended.
|
||||
const redirectTo = "/auth" + AuthAccountDisabledPath
|
||||
c.Redirect(http.StatusSeeOther, redirectTo)
|
||||
return false
|
||||
|
||||
return
|
||||
default:
|
||||
// All good.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
|
||||
returnedInternalState := c.Query(callbackStateParam)
|
||||
if returnedInternalState == "" {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
err := fmt.Errorf("%s parameter not found on callback query", callbackStateParam)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
@ -69,14 +69,14 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
savedInternalStateI := s.Get(sessionInternalState)
|
||||
savedInternalState, ok := savedInternalStateI.(string)
|
||||
if !ok {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
err := fmt.Errorf("key %s was not found in session", sessionInternalState)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if returnedInternalState != savedInternalState {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
err := errors.New("mismatch between callback state and saved state")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
@ -85,7 +85,7 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
// retrieve stored claims using code
|
||||
code := c.Query(callbackCodeParam)
|
||||
if code == "" {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
err := fmt.Errorf("%s parameter not found on callback query", callbackCodeParam)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
@ -93,7 +93,7 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
|
||||
claims, errWithCode := m.idp.HandleCallback(c.Request.Context(), code)
|
||||
if errWithCode != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
|
@ -102,15 +102,15 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
// info about the app associated with the client_id
|
||||
clientID, ok := s.Get(sessionClientID).(string)
|
||||
if !ok || clientID == "" {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
err := fmt.Errorf("key %s was not found in session", sessionClientID)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
app, err := m.db.GetApplicationByClientID(c.Request.Context(), clientID)
|
||||
app, err := m.state.DB.GetApplicationByClientID(c.Request.Context(), clientID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
|
|
@ -124,7 +124,7 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
|
||||
user, errWithCode := m.fetchUserForClaims(c.Request.Context(), claims, net.IP(c.ClientIP()), app.ID)
|
||||
if errWithCode != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
|
@ -140,7 +140,7 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
s.Set(sessionClaims, claims)
|
||||
s.Set(sessionAppID, app.ID)
|
||||
if err := s.Save(); err != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
|
@ -173,7 +173,7 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
|
||||
s.Set(sessionUserID, user.ID)
|
||||
if err := s.Save(); err != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
|
@ -186,7 +186,7 @@ func (m *Module) FinalizePOSTHandler(c *gin.Context) {
|
|||
|
||||
form := &extraInfo{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
|
@ -219,7 +219,7 @@ func (m *Module) FinalizePOSTHandler(c *gin.Context) {
|
|||
}
|
||||
|
||||
// see if the username is still available
|
||||
usernameAvailable, err := m.db.IsUsernameAvailable(c.Request.Context(), form.Username)
|
||||
usernameAvailable, err := m.state.DB.IsUsernameAvailable(c.Request.Context(), form.Username)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
@ -248,7 +248,7 @@ func (m *Module) FinalizePOSTHandler(c *gin.Context) {
|
|||
// we're now ready to actually create the user
|
||||
user, errWithCode := m.createUserFromOIDC(c.Request.Context(), claims, form, net.IP(c.ClientIP()), appID)
|
||||
if errWithCode != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
|
@ -256,7 +256,7 @@ func (m *Module) FinalizePOSTHandler(c *gin.Context) {
|
|||
s.Delete(sessionAppID)
|
||||
s.Set(sessionUserID, user.ID)
|
||||
if err := s.Save(); err != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
|
@ -268,7 +268,7 @@ func (m *Module) fetchUserForClaims(ctx context.Context, claims *oidc.Claims, ip
|
|||
err := errors.New("no sub claim found - is your provider OIDC compliant?")
|
||||
return nil, gtserror.NewErrorBadRequest(err, err.Error())
|
||||
}
|
||||
user, err := m.db.GetUserByExternalID(ctx, claims.Sub)
|
||||
user, err := m.state.DB.GetUserByExternalID(ctx, claims.Sub)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
|
|
@ -280,7 +280,7 @@ func (m *Module) fetchUserForClaims(ctx context.Context, claims *oidc.Claims, ip
|
|||
return nil, nil
|
||||
}
|
||||
// fallback to email if we want to link existing users
|
||||
user, err = m.db.GetUserByEmailAddress(ctx, claims.Email)
|
||||
user, err = m.state.DB.GetUserByEmailAddress(ctx, claims.Email)
|
||||
if err == db.ErrNoEntries {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
|
|
@ -290,7 +290,7 @@ func (m *Module) fetchUserForClaims(ctx context.Context, claims *oidc.Claims, ip
|
|||
// at this point we have found a matching user but still need to link the newly received external ID
|
||||
|
||||
user.ExternalID = claims.Sub
|
||||
err = m.db.UpdateUser(ctx, user, "external_id")
|
||||
err = m.state.DB.UpdateUser(ctx, user, "external_id")
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error linking existing user %s: %s", claims.Email, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
|
|
@ -300,7 +300,7 @@ func (m *Module) fetchUserForClaims(ctx context.Context, claims *oidc.Claims, ip
|
|||
|
||||
func (m *Module) createUserFromOIDC(ctx context.Context, claims *oidc.Claims, extraInfo *extraInfo, ip net.IP, appID string) (*gtsmodel.User, gtserror.WithCode) {
|
||||
// Check if the claimed email address is available for use.
|
||||
emailAvailable, err := m.db.IsEmailAvailable(ctx, claims.Email)
|
||||
emailAvailable, err := m.state.DB.IsEmailAvailable(ctx, claims.Email)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("db error checking email availability: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
|
|
@ -354,7 +354,7 @@ func (m *Module) createUserFromOIDC(ctx context.Context, claims *oidc.Claims, ex
|
|||
|
||||
// Create the user! This will also create an account and
|
||||
// store it in the database, so we don't need to do that.
|
||||
user, err := m.db.NewSignup(ctx, gtsmodel.NewSignup{
|
||||
user, err := m.state.DB.NewSignup(ctx, gtsmodel.NewSignup{
|
||||
Username: extraInfo.Username,
|
||||
Email: claims.Email,
|
||||
Password: password,
|
||||
|
|
|
|||
|
|
@ -18,97 +18,56 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
func (m *Module) OobHandler(c *gin.Context) {
|
||||
// OOBTokenGETHandler parses the OAuth code from the query
|
||||
// params and serves a nice little HTML page showing the code.
|
||||
func (m *Module) OOBTokenGETHandler(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
oobToken := c.Query("code")
|
||||
if oobToken == "" {
|
||||
const errText = "no 'code' query value provided in callback redirect"
|
||||
m.clearSessionWithBadRequest(c, s, errors.New(errText), errText)
|
||||
return
|
||||
}
|
||||
|
||||
user := m.mustUserFromSession(c, s)
|
||||
if user == nil {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
scope := m.mustStringFromSession(c, s, sessionScope)
|
||||
if scope == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
// We're done with
|
||||
// the session now.
|
||||
m.mustClearSession(s)
|
||||
|
||||
instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
oobToken := c.Query("code")
|
||||
if oobToken == "" {
|
||||
err := errors.New("no 'code' query value provided in callback redirect")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice), instanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
s := sessions.Default(c)
|
||||
|
||||
errs := []string{}
|
||||
|
||||
scope, ok := s.Get(sessionScope).(string)
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionScope))
|
||||
}
|
||||
|
||||
userID, ok := s.Get(sessionUserID).(string)
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionUserID))
|
||||
}
|
||||
|
||||
if len(errs) != 0 {
|
||||
errs = append(errs, oauth.HelpfulAdvice)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(errors.New("one or more missing keys on session during OobHandler"), errs...), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.db.GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("user with id %s could not be retrieved", userID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, instanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, instanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
// we're done with the session now, so just clear it out
|
||||
m.clearSession(s)
|
||||
|
||||
page := apiutil.WebPage{
|
||||
apiutil.TemplateWebPage(c, apiutil.WebPage{
|
||||
Template: "oob.tmpl",
|
||||
Instance: instance,
|
||||
Extra: map[string]any{
|
||||
"user": acct.Username,
|
||||
"user": user.Account.Username,
|
||||
"oobToken": oobToken,
|
||||
"scope": scope,
|
||||
},
|
||||
}
|
||||
|
||||
apiutil.TemplateWebPage(c, page)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,104 +22,143 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pquerna/otp/totp"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// signIn just wraps a form-submitted username (we want an email) and password
|
||||
type signIn struct {
|
||||
Email string `form:"username"`
|
||||
Password string `form:"password"`
|
||||
}
|
||||
|
||||
// SignInGETHandler should be served at https://example.org/auth/sign_in.
|
||||
// The idea is to present a sign in page to the user, where they can enter their username and password.
|
||||
// The form will then POST to the sign in page, which will be handled by SignInPOSTHandler.
|
||||
// If an idp provider is set, then the user will be redirected to that to do their sign in.
|
||||
// SignInGETHandler should be served at
|
||||
// GET https://example.org/auth/sign_in.
|
||||
//
|
||||
// The idea is to present a friendly sign-in
|
||||
// page to the user, where they can enter their
|
||||
// username and password.
|
||||
//
|
||||
// When submitted, the form will POST to the sign-
|
||||
// in page, which will be handled by SignInPOSTHandler.
|
||||
//
|
||||
// If an idp provider is set, then the user will
|
||||
// be redirected to that to do their sign in.
|
||||
func (m *Module) SignInGETHandler(c *gin.Context) {
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.HTMLAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if !config.GetOIDCEnabled() {
|
||||
instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
if config.GetOIDCEnabled() {
|
||||
// IDP provider is in use, so redirect to it
|
||||
// instead of serving our own sign in page.
|
||||
//
|
||||
// We need the internal state to know where
|
||||
// to redirect to.
|
||||
internalState := m.mustStringFromSession(
|
||||
c,
|
||||
sessions.Default(c),
|
||||
sessionInternalState,
|
||||
)
|
||||
if internalState == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
page := apiutil.WebPage{
|
||||
Template: "sign-in.tmpl",
|
||||
Instance: instance,
|
||||
}
|
||||
|
||||
apiutil.TemplateWebPage(c, page)
|
||||
c.Redirect(http.StatusSeeOther, m.idp.AuthCodeURL(internalState))
|
||||
return
|
||||
}
|
||||
|
||||
// idp provider is in use, so redirect to it
|
||||
s := sessions.Default(c)
|
||||
|
||||
internalStateI := s.Get(sessionInternalState)
|
||||
internalState, ok := internalStateI.(string)
|
||||
if !ok {
|
||||
m.clearSession(s)
|
||||
err := fmt.Errorf("key %s was not found in session", sessionInternalState)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusSeeOther, m.idp.AuthCodeURL(internalState))
|
||||
}
|
||||
|
||||
// SignInPOSTHandler should be served at https://example.org/auth/sign_in.
|
||||
// The idea is to present a sign in page to the user, where they can enter their username and password.
|
||||
// The handler will then redirect to the auth handler served at /auth
|
||||
func (m *Module) SignInPOSTHandler(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
form := &signIn{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
m.clearSession(s)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
userid, errWithCode := m.ValidatePassword(c.Request.Context(), form.Email, form.Password)
|
||||
// IDP provider is not in use.
|
||||
// Render our own cute little page.
|
||||
instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
|
||||
if errWithCode != nil {
|
||||
// don't clear session here, so the user can just press back and try again
|
||||
// if they accidentally gave the wrong password or something
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
s.Set(sessionUserID, userid)
|
||||
if err := s.Save(); err != nil {
|
||||
err := fmt.Errorf("error saving user id onto session: %s", err)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
apiutil.TemplateWebPage(c, apiutil.WebPage{
|
||||
Template: "sign-in.tmpl",
|
||||
Instance: instance,
|
||||
})
|
||||
}
|
||||
|
||||
// SignInPOSTHandler should be served at
|
||||
// POST https://example.org/auth/sign_in.
|
||||
//
|
||||
// The handler will check the submitted credentials,
|
||||
// then redirect either to the 2fa form, or straight
|
||||
// to the authorize page served at /oauth/authorize.
|
||||
func (m *Module) SignInPOSTHandler(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
// Parse email + password.
|
||||
form := &struct {
|
||||
Email string `form:"username" validate:"required"`
|
||||
Password string `form:"password" validate:"required"`
|
||||
}{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
m.clearSessionWithBadRequest(c, s, err, oauth.HelpfulAdvice)
|
||||
return
|
||||
}
|
||||
|
||||
user, errWithCode := m.validatePassword(
|
||||
c.Request.Context(),
|
||||
form.Email,
|
||||
form.Password,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
// Don't clear session here yet, so the user
|
||||
// can just press back and try again if they
|
||||
// accidentally gave the wrong password, without
|
||||
// having to do the whole sign in flow again!
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
// Whether or not 2fa is enabled, we want
|
||||
// to save the session when we're done here.
|
||||
defer m.mustSaveSession(s)
|
||||
|
||||
if user.TwoFactorEnabled() {
|
||||
// If this user has 2FA enabled, redirect
|
||||
// to the 2FA page and have them submit
|
||||
// a code from their authenticator app.
|
||||
s.Set(sessionUserIDAwaiting2FA, user.ID)
|
||||
c.Redirect(http.StatusFound, "/auth"+Auth2FAPath)
|
||||
return
|
||||
}
|
||||
|
||||
// If the user doesn't have 2fa enabled,
|
||||
// redirect straight to the OAuth authorize page.
|
||||
s.Set(sessionUserID, user.ID)
|
||||
c.Redirect(http.StatusFound, "/oauth"+OauthAuthorizePath)
|
||||
}
|
||||
|
||||
// ValidatePassword takes an email address and a password.
|
||||
// The goal is to authenticate the password against the one for that email
|
||||
// address stored in the database. If OK, we return the userid (a ulid) for that user,
|
||||
// so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db.
|
||||
func (m *Module) ValidatePassword(ctx context.Context, email string, password string) (string, gtserror.WithCode) {
|
||||
// validatePassword takes an email address and a password.
|
||||
// The func authenticates the password against the one for
|
||||
// that email address stored in the database.
|
||||
//
|
||||
// If OK, it returns the user, so that it can be used in
|
||||
// further OAuth flows to generate a token etc.
|
||||
func (m *Module) validatePassword(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
password string,
|
||||
) (*gtsmodel.User, gtserror.WithCode) {
|
||||
if email == "" || password == "" {
|
||||
err := errors.New("email or password was not provided")
|
||||
return incorrectPassword(err)
|
||||
}
|
||||
|
||||
user, err := m.db.GetUserByEmailAddress(ctx, email)
|
||||
user, err := m.state.DB.GetUserByEmailAddress(ctx, email)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err)
|
||||
return incorrectPassword(err)
|
||||
|
|
@ -130,17 +169,141 @@ func (m *Module) ValidatePassword(ctx context.Context, email string, password st
|
|||
return incorrectPassword(err)
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil {
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(user.EncryptedPassword),
|
||||
byteutil.S2B(password),
|
||||
); err != nil {
|
||||
err := fmt.Errorf("password hash didn't match for user %s during sign in attempt: %s", user.Email, err)
|
||||
return incorrectPassword(err)
|
||||
}
|
||||
|
||||
return user.ID, nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// incorrectPassword wraps the given error in a gtserror.WithCode, and returns
|
||||
// only a generic 'safe' error message to the user, to not give any info away.
|
||||
func incorrectPassword(err error) (string, gtserror.WithCode) {
|
||||
safeErr := fmt.Errorf("password/email combination was incorrect")
|
||||
return "", gtserror.NewErrorUnauthorized(err, safeErr.Error(), oauth.HelpfulAdvice)
|
||||
func incorrectPassword(err error) (*gtsmodel.User, gtserror.WithCode) {
|
||||
const errText = "password/email combination was incorrect"
|
||||
return nil, gtserror.NewErrorUnauthorized(err, errText, oauth.HelpfulAdvice)
|
||||
}
|
||||
|
||||
// TwoFactorCodeGETHandler should be served at
|
||||
// GET https://example.org/auth/2fa.
|
||||
//
|
||||
// The 2fa template displays a simple form asking the
|
||||
// user to input a code from their authenticator app.
|
||||
func (m *Module) TwoFactorCodeGETHandler(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
user := m.mustUserFromSession(c, s)
|
||||
if user == nil {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.TemplateWebPage(c, apiutil.WebPage{
|
||||
Template: "2fa.tmpl",
|
||||
Instance: instance,
|
||||
Extra: map[string]any{
|
||||
"user": user.Account.Username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TwoFactorCodePOSTHandler should be served at
|
||||
// POST https://example.org/auth/2fa.
|
||||
//
|
||||
// The idea is to handle a submitted 2fa code, validate it,
|
||||
// and if valid redirect to the /oauth/authorize page that
|
||||
// the user would get to if they didn't have 2fa enabled.
|
||||
func (m *Module) TwoFactorCodePOSTHandler(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
user := m.mustUserFromSession(c, s)
|
||||
if user == nil {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
// Parse 2fa code.
|
||||
form := &struct {
|
||||
Code string `form:"code" validate:"required"`
|
||||
}{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
m.clearSessionWithBadRequest(c, s, err, oauth.HelpfulAdvice)
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := m.validate2FACode(c, user, form.Code)
|
||||
if err != nil {
|
||||
m.clearSessionWithInternalError(c, s, err, oauth.HelpfulAdvice)
|
||||
return
|
||||
}
|
||||
|
||||
if !valid {
|
||||
// Don't clear session here yet, so the user
|
||||
// can just press back and try again if they
|
||||
// accidentally gave the wrong code, without
|
||||
// having to do the whole sign in flow again!
|
||||
const errText = "2fa code invalid or timed out, press back and try again; " +
|
||||
"if issues persist, pester your instance admin to check the server clock"
|
||||
errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
// Code looks good! Redirect
|
||||
// to the OAuth authorize page.
|
||||
s.Set(sessionUserID, user.ID)
|
||||
m.mustSaveSession(s)
|
||||
c.Redirect(http.StatusFound, "/oauth"+OauthAuthorizePath)
|
||||
}
|
||||
|
||||
func (m *Module) validate2FACode(c *gin.Context, user *gtsmodel.User, code string) (bool, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) <= 6 {
|
||||
// This is a normal authenticator
|
||||
// app code, just try to validate it.
|
||||
return totp.Validate(code, user.TwoFactorSecret), nil
|
||||
}
|
||||
|
||||
// This is a one-time recovery code.
|
||||
// Check against the user's stored codes.
|
||||
for i := 0; i < len(user.TwoFactorBackups); i++ {
|
||||
err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(user.TwoFactorBackups[i]),
|
||||
byteutil.S2B(code),
|
||||
)
|
||||
if err != nil {
|
||||
// Doesn't match,
|
||||
// try next.
|
||||
continue
|
||||
}
|
||||
|
||||
// We have a match.
|
||||
// Remove this one-time code from the user's backups.
|
||||
user.TwoFactorBackups = slices.Delete(user.TwoFactorBackups, i, i+1)
|
||||
if err := m.state.DB.UpdateUser(
|
||||
c.Request.Context(),
|
||||
user,
|
||||
"two_factor_backups",
|
||||
); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// So valid bestie!
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Not a valid one-time
|
||||
// recovery code.
|
||||
return false, nil
|
||||
}
|
||||
|
|
|
|||
152
internal/api/auth/util.go
Normal file
152
internal/api/auth/util.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
func (m *Module) mustClearSession(s sessions.Session) {
|
||||
s.Clear()
|
||||
m.mustSaveSession(s)
|
||||
}
|
||||
|
||||
func (m *Module) mustSaveSession(s sessions.Session) {
|
||||
if err := s.Save(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustUserFromSession returns a *gtsmodel.User by checking the
|
||||
// session for a user id and fetching the user from the database.
|
||||
//
|
||||
// On failure, the function clears session state, writes an internal
|
||||
// error to the response writer, and returns nil. Callers should always
|
||||
// return immediately if receiving nil back from this function!
|
||||
func (m *Module) mustUserFromSession(
|
||||
c *gin.Context,
|
||||
s sessions.Session,
|
||||
) *gtsmodel.User {
|
||||
// Try "userid" key first, fall
|
||||
// back to "userid_awaiting_2fa".
|
||||
var userID string
|
||||
for _, key := range [2]string{
|
||||
sessionUserID,
|
||||
sessionUserIDAwaiting2FA,
|
||||
} {
|
||||
var ok bool
|
||||
userID, ok = s.Get(key).(string)
|
||||
if ok && userID != "" {
|
||||
// Got it.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
const safe = "neither userid nor userid_awaiting_2fa keys found in session"
|
||||
m.clearSessionWithInternalError(c, s, errors.New(safe), safe, oauth.HelpfulAdvice)
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := m.state.DB.GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
safe := "db error getting user " + userID
|
||||
m.clearSessionWithInternalError(c, s, err, safe, oauth.HelpfulAdvice)
|
||||
return nil
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// mustAppFromSession returns a *gtsmodel.Application by checking the
|
||||
// session for an application keyid and fetching the app from the database.
|
||||
//
|
||||
// On failure, the function clears session state, writes an internal
|
||||
// error to the response writer, and returns nil. Callers should always
|
||||
// return immediately if receiving nil back from this function!
|
||||
func (m *Module) mustAppFromSession(
|
||||
c *gin.Context,
|
||||
s sessions.Session,
|
||||
) *gtsmodel.Application {
|
||||
clientID, ok := s.Get(sessionClientID).(string)
|
||||
if !ok {
|
||||
const safe = "key client_id not found in session"
|
||||
m.clearSessionWithInternalError(c, s, errors.New(safe), safe, oauth.HelpfulAdvice)
|
||||
return nil
|
||||
}
|
||||
|
||||
app, err := m.state.DB.GetApplicationByClientID(c.Request.Context(), clientID)
|
||||
if err != nil {
|
||||
safe := "db error getting app for clientID " + clientID
|
||||
m.clearSessionWithInternalError(c, s, err, safe, oauth.HelpfulAdvice)
|
||||
return nil
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// mustStringFromSession returns the string value
|
||||
// corresponding to the given session key, if any is set.
|
||||
//
|
||||
// On failure (nothing set), the function clears session
|
||||
// state, writes an internal error to the response writer,
|
||||
// and returns nil. Callers should always return immediately
|
||||
// if receiving nil back from this function!
|
||||
func (m *Module) mustStringFromSession(
|
||||
c *gin.Context,
|
||||
s sessions.Session,
|
||||
key string,
|
||||
) string {
|
||||
v, ok := s.Get(key).(string)
|
||||
if !ok {
|
||||
safe := "key " + key + " not found in session"
|
||||
m.clearSessionWithInternalError(c, s, errors.New(safe), safe, oauth.HelpfulAdvice)
|
||||
return ""
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (m *Module) clearSessionWithInternalError(
|
||||
c *gin.Context,
|
||||
s sessions.Session,
|
||||
err error,
|
||||
helpText ...string,
|
||||
) {
|
||||
m.mustClearSession(s)
|
||||
errWithCode := gtserror.NewErrorInternalError(err, helpText...)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
}
|
||||
|
||||
func (m *Module) clearSessionWithBadRequest(
|
||||
c *gin.Context,
|
||||
s sessions.Session,
|
||||
err error,
|
||||
helpText ...string,
|
||||
) {
|
||||
m.mustClearSession(s)
|
||||
errWithCode := gtserror.NewErrorBadRequest(err, helpText...)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"errors"
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/gin-gonic/gin"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
|
|
@ -87,7 +88,10 @@ func (m *Module) AccountDeletePOSTHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(authed.User.EncryptedPassword), []byte(form.Password)); err != nil {
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(authed.User.EncryptedPassword),
|
||||
byteutil.S2B(form.Password),
|
||||
); err != nil {
|
||||
err = errors.New("invalid password provided in account delete request")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"net/http"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
|
|
@ -50,11 +51,17 @@ func (suite *PasswordChangeTestSuite) TestPasswordChangePOST() {
|
|||
}
|
||||
|
||||
// new password should pass
|
||||
err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("peepeepoopoopassword"))
|
||||
err = bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(dbUser.EncryptedPassword),
|
||||
byteutil.S2B("peepeepoopoopassword"),
|
||||
)
|
||||
suite.NoError(err)
|
||||
|
||||
// old password should fail
|
||||
err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password"))
|
||||
err = bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(dbUser.EncryptedPassword),
|
||||
byteutil.S2B("password"),
|
||||
)
|
||||
suite.EqualError(err, "crypto/bcrypt: hashedPassword is not the hash of the given password")
|
||||
}
|
||||
|
||||
|
|
|
|||
353
internal/api/client/user/twofactor.go
Normal file
353
internal/api/client/user/twofactor.go
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
const OIDCTwoFactorHelp = "two factor authentication request cannot be processed by GoToSocial as this instance is running with OIDC enabled; you must use 2FA provided by your OIDC provider"
|
||||
|
||||
// TwoFactorQRCodePngGETHandler swagger:operation GET /api/v1/user/2fa/qr.png TwoFactorQRCodePngGet
|
||||
//
|
||||
// Return a QR code png to allow the authorized user to enable 2fa for their login.
|
||||
//
|
||||
// For the plaintext version of the QR code URI, call /api/v1/user/2fa/qruri instead.
|
||||
//
|
||||
// If 2fa is already enabled for this user, the QR code (with its secret) will not be shared again. Instead, code 409 Conflict will be returned. To get a fresh secret, first disable 2fa using POST /api/v1/user/2fa/disable, and then call this endpoint again.
|
||||
//
|
||||
// If the instance is running with OIDC enabled, two factor authentication cannot be turned on or off in GtS, it must be enabled or disabled using the OIDC provider. All calls to 2fa api endpoints will return 422 Unprocessable Entity while OIDC is enabled.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - user
|
||||
//
|
||||
// produces:
|
||||
// - image/png
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - read:accounts
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: QR code png
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '409':
|
||||
// description: conflict
|
||||
// '422':
|
||||
// description: unprocessable entity
|
||||
// '500':
|
||||
// description: internal error
|
||||
func (m *Module) TwoFactorQRCodePngGETHandler(c *gin.Context) {
|
||||
authed, errWithCode := apiutil.TokenAuth(c,
|
||||
true, true, true, true,
|
||||
apiutil.ScopeReadAccounts,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, "image/png"); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if config.GetOIDCEnabled() {
|
||||
err := errors.New("instance running with OIDC")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, OIDCTwoFactorHelp), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
content, errWithCode := m.processor.User().TwoFactorQRCodePngGet(c.Request.Context(), authed.User)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Close content when we're done, catch errors.
|
||||
if err := content.Content.Close(); err != nil {
|
||||
log.Errorf(c.Request.Context(), "error closing readcloser: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
c.DataFromReader(
|
||||
http.StatusOK,
|
||||
content.ContentLength,
|
||||
content.ContentType,
|
||||
content.Content,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// TwoFactorQRCodeURIGETHandler swagger:operation GET /api/v1/user/2fa/qruri TwoFactorQRCodeURIGet
|
||||
//
|
||||
// Return a QR code uri to allow the authorized user to enable 2fa for their login.
|
||||
//
|
||||
// For a png of the QR code, call /api/v1/user/2fa/qr.png instead.
|
||||
//
|
||||
// If 2fa is already enabled for this user, the QR code URI (with its secret) will not be shared again. Instead, code 409 Conflict will be returned. To get a fresh secret, first disable 2fa using POST /api/v1/user/2fa/disable, and then call this endpoint again.
|
||||
//
|
||||
// If the instance is running with OIDC enabled, two factor authentication cannot be turned on or off in GtS, it must be enabled or disabled using the OIDC provider. All calls to 2fa api endpoints will return 422 Unprocessable Entity while OIDC is enabled.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - user
|
||||
//
|
||||
// produces:
|
||||
// - text/plain
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - read:accounts
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: QR code uri
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '409':
|
||||
// description: conflict
|
||||
// '422':
|
||||
// description: unprocessable entity
|
||||
// '500':
|
||||
// description: internal error
|
||||
func (m *Module) TwoFactorQRCodeURIGETHandler(c *gin.Context) {
|
||||
authed, errWithCode := apiutil.TokenAuth(c,
|
||||
true, true, true, true,
|
||||
apiutil.ScopeReadAccounts,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.TextPlain); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if config.GetOIDCEnabled() {
|
||||
err := errors.New("instance running with OIDC")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, OIDCTwoFactorHelp), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
uri, errWithCode := m.processor.User().TwoFactorQRCodeURIGet(c.Request.Context(), authed.User)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.Data(
|
||||
c,
|
||||
http.StatusOK,
|
||||
apiutil.TextPlain,
|
||||
[]byte(uri.String()),
|
||||
)
|
||||
}
|
||||
|
||||
// TwoFactorEnablePOSTHandler swagger:operation POST /api/v1/user/2fa/enable TwoFactorEnablePost
|
||||
//
|
||||
// Enable 2fa for the authorized user, using the provided code from an authenticator app, and return an array of one-time recovery codes to allow bypassing 2fa.
|
||||
//
|
||||
// If 2fa is already enabled for this user, code 409 Conflict will be returned.
|
||||
//
|
||||
// If the instance is running with OIDC enabled, two factor authentication cannot be turned on or off in GtS, it must be enabled or disabled using the OIDC provider. All calls to 2fa api endpoints will return 422 Unprocessable Entity while OIDC is enabled.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - user
|
||||
//
|
||||
// consumes:
|
||||
// - application/json
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: code
|
||||
// type: string
|
||||
// description: |-
|
||||
// 2fa code from the user's authenticator app.
|
||||
// Sample: 123456
|
||||
// in: formData
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - write:accounts
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: QR code
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '409':
|
||||
// description: conflict
|
||||
// '422':
|
||||
// description: unprocessable entity
|
||||
// '500':
|
||||
// description: internal error
|
||||
func (m *Module) TwoFactorEnablePOSTHandler(c *gin.Context) {
|
||||
authed, errWithCode := apiutil.TokenAuth(c,
|
||||
true, true, true, true,
|
||||
apiutil.ScopeWriteAccounts,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if config.GetOIDCEnabled() {
|
||||
err := errors.New("instance running with OIDC")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, OIDCPasswordHelp), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
form := &struct {
|
||||
Code string `json:"code" form:"code" validation:"required"`
|
||||
}{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
recoveryCodes, errWithCode := m.processor.User().TwoFactorEnable(
|
||||
c.Request.Context(),
|
||||
authed.User,
|
||||
form.Code,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, recoveryCodes)
|
||||
}
|
||||
|
||||
// TwoFactorDisablePOSTHandler swagger:operation POST /api/v1/user/2fa/disable TwoFactorDisablePost
|
||||
//
|
||||
// Disable 2fa for the authorized user. User's current password must be provided for verification purposes.
|
||||
//
|
||||
// If 2fa is already disabled for this user, code 409 Conflict will be returned.
|
||||
//
|
||||
// If the instance is running with OIDC enabled, two factor authentication cannot be turned on or off in GtS, it must be enabled or disabled using the OIDC provider. All calls to 2fa api endpoints will return 422 Unprocessable Entity while OIDC is enabled.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - user
|
||||
//
|
||||
// consumes:
|
||||
// - application/json
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: password
|
||||
// type: string
|
||||
// description: User's current password, for verification.
|
||||
// in: formData
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - write:accounts
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: QR code
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '409':
|
||||
// description: conflict
|
||||
// '422':
|
||||
// description: unprocessable entity
|
||||
// '500':
|
||||
// description: internal error
|
||||
func (m *Module) TwoFactorDisablePOSTHandler(c *gin.Context) {
|
||||
authed, errWithCode := apiutil.TokenAuth(c,
|
||||
true, true, true, true,
|
||||
apiutil.ScopeWriteAccounts,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if config.GetOIDCEnabled() {
|
||||
err := errors.New("instance running with OIDC")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, OIDCPasswordHelp), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
form := &struct {
|
||||
Password string `json:"password" form:"password" validation:"required"`
|
||||
}{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if errWithCode := m.processor.User().TwoFactorDisable(
|
||||
c.Request.Context(),
|
||||
authed.User,
|
||||
form.Password,
|
||||
); errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
|
@ -25,12 +25,14 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
// BasePath is the base URI path for this module, minus the 'api' prefix
|
||||
BasePath = "/v1/user"
|
||||
// PasswordChangePath is the path for POSTing a password change request.
|
||||
PasswordChangePath = BasePath + "/password_change"
|
||||
// EmailChangePath is the path for POSTing an email address change request.
|
||||
EmailChangePath = BasePath + "/email_change"
|
||||
BasePath = "/v1/user"
|
||||
PasswordChangePath = BasePath + "/password_change"
|
||||
EmailChangePath = BasePath + "/email_change"
|
||||
TwoFactorPath = BasePath + "/2fa"
|
||||
TwoFactorQRCodePngPath = TwoFactorPath + "/qr.png"
|
||||
TwoFactorQRCodeURIPath = TwoFactorPath + "/qruri"
|
||||
TwoFactorEnablePath = TwoFactorPath + "/enable"
|
||||
TwoFactorDisablePath = TwoFactorPath + "/disable"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
|
|
@ -47,4 +49,8 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
|
|||
attachHandler(http.MethodGet, BasePath, m.UserGETHandler)
|
||||
attachHandler(http.MethodPost, PasswordChangePath, m.PasswordChangePOSTHandler)
|
||||
attachHandler(http.MethodPost, EmailChangePath, m.EmailChangePOSTHandler)
|
||||
attachHandler(http.MethodGet, TwoFactorQRCodePngPath, m.TwoFactorQRCodePngGETHandler)
|
||||
attachHandler(http.MethodGet, TwoFactorQRCodeURIPath, m.TwoFactorQRCodeURIGETHandler)
|
||||
attachHandler(http.MethodPost, TwoFactorEnablePath, m.TwoFactorEnablePOSTHandler)
|
||||
attachHandler(http.MethodPost, TwoFactorDisablePath, m.TwoFactorDisablePOSTHandler)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@ type OAuthAuthorize struct {
|
|||
// Forces the user to re-login, which is necessary for authorizing with multiple accounts from the same instance.
|
||||
ForceLogin string `form:"force_login" json:"force_login"`
|
||||
// Should be set equal to `code`.
|
||||
ResponseType string `form:"response_type" json:"response_type"`
|
||||
ResponseType string `form:"response_type" json:"response_type" validate:"required"`
|
||||
// Client ID, obtained during app registration.
|
||||
ClientID string `form:"client_id" json:"client_id"`
|
||||
ClientID string `form:"client_id" json:"client_id" validate:"required"`
|
||||
// Set a URI to redirect the user to.
|
||||
// If this parameter is set to urn:ietf:wg:oauth:2.0:oob then the authorization code will be shown instead.
|
||||
// Must match one of the redirect URIs declared during app registration.
|
||||
RedirectURI string `form:"redirect_uri" json:"redirect_uri"`
|
||||
RedirectURI string `form:"redirect_uri" json:"redirect_uri" validate:"required"`
|
||||
// List of requested OAuth scopes, separated by spaces (or by pluses, if using query parameters).
|
||||
// Must be a subset of scopes declared during app registration. If not provided, defaults to read.
|
||||
Scope string `form:"scope" json:"scope"`
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ type User struct {
|
|||
// Time when the last "please reset your password" email was sent, if at all. (ISO 8601 Datetime)
|
||||
// example: 2021-07-30T09:20:25+00:00
|
||||
ResetPasswordSentAt string `json:"reset_password_sent_at,omitempty"`
|
||||
// Time at which 2fa was enabled for this user. (ISO 8601 Datetime)
|
||||
// example: 2021-07-30T09:20:25+00:00
|
||||
TwoFactorEnabledAt string `json:"two_factor_enabled_at,omitempty"`
|
||||
}
|
||||
|
||||
// PasswordChangeRequest models user password change parameters.
|
||||
|
|
|
|||
70
internal/db/bundb/migrations/20250324173534_2fa.go
Normal file
70
internal/db/bundb/migrations/20250324173534_2fa.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
newmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20250324173534_2fa"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
up := func(ctx context.Context, db *bun.DB) error {
|
||||
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
log.Info(ctx, "adding new 2fa columns to user table...")
|
||||
|
||||
var newUser *newmodel.User
|
||||
newUserType := reflect.TypeOf(newUser)
|
||||
|
||||
for _, column := range []string{
|
||||
"TwoFactorSecret",
|
||||
"TwoFactorBackups",
|
||||
"TwoFactorEnabledAt",
|
||||
} {
|
||||
// Generate new column definition from bun.
|
||||
colDef, err := getBunColumnDef(tx, newUserType, column)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error making column def: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.
|
||||
NewAddColumn().
|
||||
Model(newUser).
|
||||
ColumnExpr(colDef).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error adding column: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
down := func(ctx context.Context, db *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := Migrations.Register(up, down); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
52
internal/db/bundb/migrations/20250324173534_2fa/user.go
Normal file
52
internal/db/bundb/migrations/20250324173534_2fa/user.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
package gtsmodel
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
|
||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
|
||||
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
|
||||
Email string `bun:",nullzero,unique"`
|
||||
AccountID string `bun:"type:CHAR(26),nullzero,notnull,unique"`
|
||||
EncryptedPassword string `bun:",nullzero,notnull"`
|
||||
TwoFactorSecret string `bun:",nullzero"`
|
||||
TwoFactorBackups []string `bun:",nullzero,array"`
|
||||
TwoFactorEnabledAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
SignUpIP net.IP `bun:",nullzero"`
|
||||
InviteID string `bun:"type:CHAR(26),nullzero"`
|
||||
Reason string `bun:",nullzero"`
|
||||
Locale string `bun:",nullzero"`
|
||||
CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"`
|
||||
LastEmailedAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
ConfirmationToken string `bun:",nullzero"`
|
||||
ConfirmationSentAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
ConfirmedAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
UnconfirmedEmail string `bun:",nullzero"`
|
||||
Moderator *bool `bun:",nullzero,notnull,default:false"`
|
||||
Admin *bool `bun:",nullzero,notnull,default:false"`
|
||||
Disabled *bool `bun:",nullzero,notnull,default:false"`
|
||||
Approved *bool `bun:",nullzero,notnull,default:false"`
|
||||
ResetPasswordToken string `bun:",nullzero"`
|
||||
ResetPasswordSentAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
ExternalID string `bun:",nullzero,unique"`
|
||||
}
|
||||
|
|
@ -31,49 +31,163 @@ import (
|
|||
// Sign-ups that have been denied rather than
|
||||
// approved are stored as DeniedUser instead.
|
||||
type User struct {
|
||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
|
||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
|
||||
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
|
||||
Email string `bun:",nullzero,unique"` // confirmed email address for this user, this should be unique -- only one email address registered per instance, multiple users per email are not supported
|
||||
AccountID string `bun:"type:CHAR(26),nullzero,notnull,unique"` // The id of the local gtsmodel.Account entry for this user.
|
||||
Account *Account `bun:"rel:belongs-to"` // Pointer to the account of this user that corresponds to AccountID.
|
||||
EncryptedPassword string `bun:",nullzero,notnull"` // The encrypted password of this user, generated using https://pkg.go.dev/golang.org/x/crypto/bcrypt#GenerateFromPassword. A salt is included so we're safe against 🌈 tables.
|
||||
SignUpIP net.IP `bun:",nullzero"` // IP this user used to sign up. Only stored for pending sign-ups.
|
||||
InviteID string `bun:"type:CHAR(26),nullzero"` // id of the user who invited this user (who let this joker in?)
|
||||
Reason string `bun:",nullzero"` // What reason was given for signing up when this user was created?
|
||||
Locale string `bun:",nullzero"` // In what timezone/locale is this user located?
|
||||
CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application id created this user? See gtsmodel.Application
|
||||
CreatedByApplication *Application `bun:"rel:belongs-to"` // Pointer to the application corresponding to createdbyapplicationID.
|
||||
LastEmailedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this user last contacted by email.
|
||||
ConfirmationToken string `bun:",nullzero"` // What confirmation token did we send this user/what are we expecting back?
|
||||
ConfirmationSentAt time.Time `bun:"type:timestamptz,nullzero"` // When did we send email confirmation to this user?
|
||||
ConfirmedAt time.Time `bun:"type:timestamptz,nullzero"` // When did the user confirm their email address
|
||||
UnconfirmedEmail string `bun:",nullzero"` // Email address that hasn't yet been confirmed
|
||||
Moderator *bool `bun:",nullzero,notnull,default:false"` // Is this user a moderator?
|
||||
Admin *bool `bun:",nullzero,notnull,default:false"` // Is this user an admin?
|
||||
Disabled *bool `bun:",nullzero,notnull,default:false"` // Is this user disabled from posting?
|
||||
Approved *bool `bun:",nullzero,notnull,default:false"` // Has this user been approved by a moderator?
|
||||
ResetPasswordToken string `bun:",nullzero"` // The generated token that the user can use to reset their password
|
||||
ResetPasswordSentAt time.Time `bun:"type:timestamptz,nullzero"` // When did we email the user their reset-password email?
|
||||
ExternalID string `bun:",nullzero,unique"` // If the login for the user is managed externally (e.g OIDC), we need to keep a stable reference to the external object (e.g OIDC sub claim)
|
||||
// Database ID of the user.
|
||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
|
||||
|
||||
// Datetime when the user was created.
|
||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
|
||||
|
||||
// Datetime when was the user was last updated.
|
||||
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
|
||||
|
||||
// Confirmed email address for this user.
|
||||
//
|
||||
// This should be unique, ie., only one email
|
||||
// address registered per instance. Multiple
|
||||
// users per email are not (yet) supported.
|
||||
Email string `bun:",nullzero,unique"`
|
||||
|
||||
// Database ID of the Account for this user.
|
||||
AccountID string `bun:"type:CHAR(26),nullzero,notnull,unique"`
|
||||
|
||||
// Account corresponding to AccountID.
|
||||
Account *Account `bun:"-"`
|
||||
|
||||
// Bcrypt-encrypted password of this user, generated using
|
||||
// https://pkg.go.dev/golang.org/x/crypto/bcrypt#GenerateFromPassword.
|
||||
//
|
||||
// A salt is included so we're safe against 🌈 tables.
|
||||
EncryptedPassword string `bun:",nullzero,notnull"`
|
||||
|
||||
// 2FA secret for this user.
|
||||
//
|
||||
// Null if 2FA is not enabled for this user.
|
||||
TwoFactorSecret string `bun:",nullzero"`
|
||||
|
||||
// Slice of bcrypt-encrypted backup/recovery codes that a
|
||||
// user can use if they lose their 2FA authenticator app.
|
||||
//
|
||||
// Null if 2FA is not enabled for this user.
|
||||
TwoFactorBackups []string `bun:",nullzero,array"`
|
||||
|
||||
// Datetime when 2fa was enabled.
|
||||
//
|
||||
// Null if 2fa is not enabled for this user.
|
||||
TwoFactorEnabledAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
|
||||
// IP this user used to sign up.
|
||||
//
|
||||
// Only stored for pending sign-ups.
|
||||
SignUpIP net.IP `bun:",nullzero"`
|
||||
|
||||
// Database ID of the invite that this
|
||||
// user used to sign up, if applicable.
|
||||
InviteID string `bun:"type:CHAR(26),nullzero"`
|
||||
|
||||
// Reason given for signing up
|
||||
// when this user was created.
|
||||
Reason string `bun:",nullzero"`
|
||||
|
||||
// Timezone/locale in which
|
||||
// this user is located.
|
||||
Locale string `bun:",nullzero"`
|
||||
|
||||
// Database ID of the Application used to create this user.
|
||||
CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"`
|
||||
|
||||
// Application corresponding to ApplicationID.
|
||||
CreatedByApplication *Application `bun:"-"`
|
||||
|
||||
// Datetime when this user was last contacted by email.
|
||||
LastEmailedAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
|
||||
// Confirmation token emailed to this user.
|
||||
//
|
||||
// Only set if user's email not yet confirmed.
|
||||
ConfirmationToken string `bun:",nullzero"`
|
||||
|
||||
// Datetime when confirmation token was emailed to user.
|
||||
ConfirmationSentAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
|
||||
// Datetime when user confirmed
|
||||
// their email address, if applicable.
|
||||
ConfirmedAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
|
||||
// Email address that hasn't yet been confirmed.
|
||||
UnconfirmedEmail string `bun:",nullzero"`
|
||||
|
||||
// True if user has moderator role.
|
||||
Moderator *bool `bun:",nullzero,notnull,default:false"`
|
||||
|
||||
// True if user has admin role.
|
||||
Admin *bool `bun:",nullzero,notnull,default:false"`
|
||||
|
||||
// True if user is disabled from posting.
|
||||
Disabled *bool `bun:",nullzero,notnull,default:false"`
|
||||
|
||||
// True if this user's sign up has
|
||||
// been approved by a moderator or admin.
|
||||
Approved *bool `bun:",nullzero,notnull,default:false"`
|
||||
|
||||
// Reset password token that the user
|
||||
// can use to reset their password.
|
||||
ResetPasswordToken string `bun:",nullzero"`
|
||||
|
||||
// Datetime when reset password token was emailed to user.
|
||||
ResetPasswordSentAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
|
||||
// If the login for the user is managed
|
||||
// externally (e.g., via OIDC), this is a stable
|
||||
// reference to the external object (e.g OIDC sub claim).
|
||||
ExternalID string `bun:",nullzero,unique"`
|
||||
}
|
||||
|
||||
func (u *User) TwoFactorEnabled() bool {
|
||||
return !u.TwoFactorEnabledAt.IsZero()
|
||||
}
|
||||
|
||||
// DeniedUser represents one user sign-up that
|
||||
// was submitted to the instance and denied.
|
||||
type DeniedUser struct {
|
||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
|
||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
|
||||
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
|
||||
Email string `bun:",nullzero,notnull"` // Email address provided on the sign-up form.
|
||||
Username string `bun:",nullzero,notnull"` // Username provided on the sign-up form.
|
||||
SignUpIP net.IP `bun:",nullzero"` // IP address the sign-up originated from.
|
||||
InviteID string `bun:"type:CHAR(26),nullzero"` // Invite ID provided on the sign-up form (if applicable).
|
||||
Locale string `bun:",nullzero"` // Locale provided on the sign-up form.
|
||||
CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"` // ID of application used to create this sign-up.
|
||||
SignUpReason string `bun:",nullzero"` // Reason provided by user on the sign-up form.
|
||||
PrivateComment string `bun:",nullzero"` // Comment from instance admin about why this sign-up was denied.
|
||||
SendEmail *bool `bun:",nullzero,notnull,default:false"` // Send an email informing user that their sign-up has been denied.
|
||||
Message string `bun:",nullzero"` // Message to include when sending an email to the denied user's email address, if SendEmail is true.
|
||||
// Database ID of the user.
|
||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
|
||||
|
||||
// Datetime when the user was denied.
|
||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
|
||||
|
||||
// Datetime when the denied user was last updated.
|
||||
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
|
||||
|
||||
// Email address provided on the sign-up form.
|
||||
Email string `bun:",nullzero,notnull"`
|
||||
|
||||
// Username provided on the sign-up form.
|
||||
Username string `bun:",nullzero,notnull"`
|
||||
|
||||
// IP address the sign-up originated from.
|
||||
SignUpIP net.IP `bun:",nullzero"`
|
||||
|
||||
// Invite ID provided on the sign-up form (if applicable).
|
||||
InviteID string `bun:"type:CHAR(26),nullzero"`
|
||||
|
||||
// Locale provided on the sign-up form.
|
||||
Locale string `bun:",nullzero"`
|
||||
|
||||
// ID of application used to create this sign-up.
|
||||
CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"`
|
||||
|
||||
// Reason provided by user on the sign-up form.
|
||||
SignUpReason string `bun:",nullzero"`
|
||||
|
||||
// Comment from instance admin about why this sign-up was denied.
|
||||
PrivateComment string `bun:",nullzero"`
|
||||
|
||||
// Send an email informing user that their sign-up has been denied.
|
||||
SendEmail *bool `bun:",nullzero,notnull,default:false"`
|
||||
|
||||
// Message to include when sending an email to the
|
||||
// denied user's email address, if SendEmail is true.
|
||||
Message string `bun:",nullzero"`
|
||||
}
|
||||
|
||||
// NewSignup models parameters for the creation
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import (
|
|||
"slices"
|
||||
"time"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
|
|
@ -70,8 +71,8 @@ func (p *Processor) MoveSelf(
|
|||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
[]byte(authed.User.EncryptedPassword),
|
||||
[]byte(form.Password),
|
||||
byteutil.S2B(authed.User.EncryptedPassword),
|
||||
byteutil.S2B(form.Password),
|
||||
); err != nil {
|
||||
const text = "invalid password provided in Move request"
|
||||
return gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
|
|
@ -41,7 +42,10 @@ func (p *Processor) EmailChange(
|
|||
newEmail string,
|
||||
) (*apimodel.User, gtserror.WithCode) {
|
||||
// Ensure provided password is correct.
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil {
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(user.EncryptedPassword),
|
||||
byteutil.S2B(password),
|
||||
); err != nil {
|
||||
err := gtserror.Newf("%w", err)
|
||||
return nil, gtserror.NewErrorUnauthorized(err, "password was incorrect")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ package user
|
|||
import (
|
||||
"context"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/validate"
|
||||
|
|
@ -29,7 +30,10 @@ import (
|
|||
// PasswordChange processes a password change request for the given user.
|
||||
func (p *Processor) PasswordChange(ctx context.Context, user *gtsmodel.User, oldPassword string, newPassword string) gtserror.WithCode {
|
||||
// Ensure provided oldPassword is the correct current password.
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(oldPassword)); err != nil {
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(user.EncryptedPassword),
|
||||
byteutil.S2B(oldPassword),
|
||||
); err != nil {
|
||||
err := gtserror.Newf("%w", err)
|
||||
return gtserror.NewErrorUnauthorized(err, "old password was incorrect")
|
||||
}
|
||||
|
|
@ -48,7 +52,7 @@ func (p *Processor) PasswordChange(ctx context.Context, user *gtsmodel.User, old
|
|||
|
||||
// Hash the new password.
|
||||
encryptedPassword, err := bcrypt.GenerateFromPassword(
|
||||
[]byte(newPassword),
|
||||
byteutil.S2B(newPassword),
|
||||
bcrypt.DefaultCost,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"net/http"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
|
@ -37,7 +38,10 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordOK() {
|
|||
errWithCode := suite.user.PasswordChange(context.Background(), user, "password", "verygoodnewpassword")
|
||||
suite.NoError(errWithCode)
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte("verygoodnewpassword"))
|
||||
err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(user.EncryptedPassword),
|
||||
byteutil.S2B("verygoodnewpassword"),
|
||||
)
|
||||
suite.NoError(err)
|
||||
|
||||
// get user from the db again
|
||||
|
|
@ -46,7 +50,10 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordOK() {
|
|||
suite.NoError(err)
|
||||
|
||||
// check the password has changed
|
||||
err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("verygoodnewpassword"))
|
||||
err = bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(dbUser.EncryptedPassword),
|
||||
byteutil.S2B("verygoodnewpassword"),
|
||||
)
|
||||
suite.NoError(err)
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +71,10 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordIncorrectOld() {
|
|||
suite.NoError(err)
|
||||
|
||||
// check the password has not changed
|
||||
err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password"))
|
||||
err = bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(dbUser.EncryptedPassword),
|
||||
byteutil.S2B("password"),
|
||||
)
|
||||
suite.NoError(err)
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +92,10 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordWeakNew() {
|
|||
suite.NoError(err)
|
||||
|
||||
// check the password has not changed
|
||||
err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password"))
|
||||
err = bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(dbUser.EncryptedPassword),
|
||||
byteutil.S2B("password"),
|
||||
)
|
||||
suite.NoError(err)
|
||||
}
|
||||
|
||||
|
|
|
|||
278
internal/processing/user/twofactor.go
Normal file
278
internal/processing/user/twofactor.go
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var b32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// EncodeQuery is a copy-paste of url.Values.Encode, except it uses
|
||||
// %20 instead of + to encode spaces. This is necessary to correctly
|
||||
// render spaces in some authenticator apps, like Google Authenticator.
|
||||
//
|
||||
// [Note: this func and the above comment are both taken
|
||||
// directly from github.com/pquerna/otp/internal/encode.go.]
|
||||
func encodeQuery(v url.Values) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
var buf strings.Builder
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
vs := v[k]
|
||||
// Changed from url.QueryEscape.
|
||||
keyEscaped := url.PathEscape(k)
|
||||
for _, v := range vs {
|
||||
if buf.Len() > 0 {
|
||||
buf.WriteByte('&')
|
||||
}
|
||||
buf.WriteString(keyEscaped)
|
||||
buf.WriteByte('=')
|
||||
// Changed from url.QueryEscape.
|
||||
buf.WriteString(url.PathEscape(v))
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// totpURLForUser reconstructs a TOTP URL for the
|
||||
// given user, setting the instance host as issuer.
|
||||
//
|
||||
// See https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
||||
func totpURLForUser(user *gtsmodel.User) *url.URL {
|
||||
issuer := config.GetHost() + " - GoToSocial"
|
||||
v := url.Values{}
|
||||
v.Set("secret", user.TwoFactorSecret)
|
||||
v.Set("issuer", issuer)
|
||||
v.Set("period", "30") // 30 seconds totp validity.
|
||||
v.Set("algorithm", "SHA1")
|
||||
v.Set("digits", "6") // 6-digit totp.
|
||||
|
||||
return &url.URL{
|
||||
Scheme: "otpauth",
|
||||
Host: "totp",
|
||||
Path: "/" + issuer + ":" + user.Email,
|
||||
RawQuery: encodeQuery(v),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) TwoFactorQRCodePngGet(
|
||||
ctx context.Context,
|
||||
user *gtsmodel.User,
|
||||
) (*apimodel.Content, gtserror.WithCode) {
|
||||
// Get the 2FA url for this user.
|
||||
totpURI, errWithCode := p.TwoFactorQRCodeURIGet(ctx, user)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
key, err := otp.NewKeyFromURL(totpURI.String())
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error creating totp key from url: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Spawn a QR code image from the key.
|
||||
qr, err := key.Image(256, 256)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error creating qr image from key: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Blat the key into a buffer.
|
||||
buf := new(bytes.Buffer)
|
||||
if err := png.Encode(buf, qr); err != nil {
|
||||
err := gtserror.Newf("error encoding qr image to png: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Return it as our nice content model.
|
||||
return &apimodel.Content{
|
||||
ContentType: "image/png",
|
||||
ContentLength: int64(buf.Len()),
|
||||
Content: io.NopCloser(buf),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Processor) TwoFactorQRCodeURIGet(
|
||||
ctx context.Context,
|
||||
user *gtsmodel.User,
|
||||
) (*url.URL, gtserror.WithCode) {
|
||||
// Check if we need to lazily
|
||||
// generate a new 2fa secret.
|
||||
if user.TwoFactorSecret == "" {
|
||||
// We do! Read some random crap.
|
||||
// 32 bytes should be plenty entropy.
|
||||
secret := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
|
||||
err := gtserror.Newf("error generating new secret: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Set + store the secret.
|
||||
user.TwoFactorSecret = b32NoPadding.EncodeToString(secret)
|
||||
if err := p.state.DB.UpdateUser(ctx, user, "two_factor_secret"); err != nil {
|
||||
err := gtserror.Newf("db error updating user: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
} else if user.TwoFactorEnabled() {
|
||||
// If a secret is already set, and 2fa is
|
||||
// already enabled, we shouldn't share the
|
||||
// secret via QR code again: Someone may
|
||||
// have obtained a token for this user and
|
||||
// is trying to get the 2fa secret so they
|
||||
// can escalate an attack or something.
|
||||
const errText = "2fa already enabled; keeping the secret secret"
|
||||
return nil, gtserror.NewErrorConflict(errors.New(errText), errText)
|
||||
}
|
||||
|
||||
// Recreate the totp key.
|
||||
return totpURLForUser(user), nil
|
||||
}
|
||||
|
||||
func (p *Processor) TwoFactorEnable(
|
||||
ctx context.Context,
|
||||
user *gtsmodel.User,
|
||||
code string,
|
||||
) ([]string, gtserror.WithCode) {
|
||||
if user.TwoFactorSecret == "" {
|
||||
// User doesn't have a secret set, which
|
||||
// means they never got the QR code to scan
|
||||
// into their authenticator app. We can safely
|
||||
// return an error from this request.
|
||||
const errText = "no 2fa secret stored yet; read the qr code first"
|
||||
return nil, gtserror.NewErrorForbidden(errors.New(errText), errText)
|
||||
}
|
||||
|
||||
if user.TwoFactorEnabled() {
|
||||
const errText = "2fa already enabled; disable it first then try again"
|
||||
return nil, gtserror.NewErrorConflict(errors.New(errText), errText)
|
||||
}
|
||||
|
||||
// Try validating the provided code and give
|
||||
// a helpful error message if it doesn't work.
|
||||
if !totp.Validate(code, user.TwoFactorSecret) {
|
||||
const errText = "invalid code provided, you may have been too late, try again; " +
|
||||
"if it keeps not working, pester your admin to check that the server clock is correct"
|
||||
return nil, gtserror.NewErrorForbidden(errors.New(errText), errText)
|
||||
}
|
||||
|
||||
// Valid code was provided so we
|
||||
// should turn 2fa on for this user.
|
||||
user.TwoFactorEnabledAt = time.Now()
|
||||
|
||||
// Create recovery codes in cleartext
|
||||
// to show to the user ONCE ONLY.
|
||||
backupsClearText := make([]string, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
backupsClearText[i] = util.MustGenerateSecret()
|
||||
}
|
||||
|
||||
// Store only the bcrypt-encrypted
|
||||
// versions of the recovery codes.
|
||||
user.TwoFactorBackups = make([]string, 8)
|
||||
for i, backup := range backupsClearText {
|
||||
encryptedBackup, err := bcrypt.GenerateFromPassword(
|
||||
byteutil.S2B(backup),
|
||||
bcrypt.DefaultCost,
|
||||
)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error encrypting backup codes: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
user.TwoFactorBackups[i] = string(encryptedBackup)
|
||||
}
|
||||
|
||||
if err := p.state.DB.UpdateUser(
|
||||
ctx,
|
||||
user,
|
||||
"two_factor_enabled_at",
|
||||
"two_factor_backups",
|
||||
); err != nil {
|
||||
err := gtserror.Newf("db error updating user: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return backupsClearText, nil
|
||||
}
|
||||
|
||||
func (p *Processor) TwoFactorDisable(
|
||||
ctx context.Context,
|
||||
user *gtsmodel.User,
|
||||
password string,
|
||||
) gtserror.WithCode {
|
||||
if !user.TwoFactorEnabled() {
|
||||
const errText = "2fa already disabled"
|
||||
return gtserror.NewErrorConflict(errors.New(errText), errText)
|
||||
}
|
||||
|
||||
// Ensure provided password is correct.
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(user.EncryptedPassword),
|
||||
byteutil.S2B(password),
|
||||
); err != nil {
|
||||
const errText = "incorrect password"
|
||||
return gtserror.NewErrorUnauthorized(errors.New(errText), errText)
|
||||
}
|
||||
|
||||
// Disable 2fa for this user
|
||||
// and clear backup codes.
|
||||
user.TwoFactorEnabledAt = time.Time{}
|
||||
user.TwoFactorSecret = ""
|
||||
user.TwoFactorBackups = nil
|
||||
if err := p.state.DB.UpdateUser(
|
||||
ctx,
|
||||
user,
|
||||
"two_factor_enabled_at",
|
||||
"two_factor_secret",
|
||||
"two_factor_backups",
|
||||
); err != nil {
|
||||
err := gtserror.Newf("db error updating user: %w", err)
|
||||
return gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -99,6 +99,10 @@ func (c *Converter) UserToAPIUser(ctx context.Context, u *gtsmodel.User) *apimod
|
|||
user.ResetPasswordSentAt = util.FormatISO8601(u.ResetPasswordSentAt)
|
||||
}
|
||||
|
||||
if !u.TwoFactorEnabledAt.IsZero() {
|
||||
user.TwoFactorEnabledAt = util.FormatISO8601(u.TwoFactorEnabledAt)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
|
|
|
|||
47
internal/util/secret.go
Normal file
47
internal/util/secret.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"io"
|
||||
)
|
||||
|
||||
// crockfordBase32 is an encoding alphabet that misses characters I,L,O,U,
|
||||
// to avoid confusion and abuse. See: http://www.crockford.com/wrmg/base32.html
|
||||
const crockfordBase32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
// base32enc is a pre-initialized CrockfordBase32 encoding without any padding.
|
||||
var base32enc = base32.NewEncoding(crockfordBase32).WithPadding(base32.NoPadding)
|
||||
|
||||
// MustGenerateSecret returns a cryptographically-secure,
|
||||
// CrockfordBase32-encoded string of 32 chars in length
|
||||
// (ie., 20-bytes/160 bits of entropy), or panics on error.
|
||||
//
|
||||
// The source of randomness is crypto/rand.
|
||||
func MustGenerateSecret() string {
|
||||
// Crockford base32 with no padding
|
||||
// encodes 20 bytes to 32 characters.
|
||||
const blen = 20
|
||||
b := make([]byte, blen)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return base32enc.EncodeToString(b)
|
||||
}
|
||||
40
internal/util/secret_test.go
Normal file
40
internal/util/secret_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
func TestMustGenerateSecret(t *testing.T) {
|
||||
var (
|
||||
rStr = `^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{32}$`
|
||||
r = regexp.MustCompile(rStr)
|
||||
)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
secret := util.MustGenerateSecret()
|
||||
if !r.MatchString(secret) {
|
||||
t.Logf("%d: secret %s does not match regex %s", i, secret, rStr)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue