[chore] Admin CLI + new account creation refactoring (#2008)

* set maxPasswordLength to 72 bytes, rename validate function

* refactor NewSignup

* refactor admin account CLI commands

* refactor oidc create user

* refactor processor create

* tweak password change, check old != new password
This commit is contained in:
tobi 2023-07-23 12:33:17 +02:00 committed by GitHub
parent f8f0312042
commit 5a29a031ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 373 additions and 276 deletions

View file

@ -19,7 +19,6 @@
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"text/tabwriter" "text/tabwriter"
@ -28,88 +27,101 @@
"github.com/superseriousbusiness/gotosocial/cmd/gotosocial/action" "github.com/superseriousbusiness/gotosocial/cmd/gotosocial/action"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db/bundb" "github.com/superseriousbusiness/gotosocial/internal/db/bundb"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/validate" "github.com/superseriousbusiness/gotosocial/internal/validate"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// Create creates a new account in the database using the provided flags. func initState(ctx context.Context) (*state.State, error) {
var Create action.GTSAction = func(ctx context.Context) error {
var state state.State var state state.State
state.Caches.Init() state.Caches.Init()
state.Caches.Start()
state.Workers.Start() state.Workers.Start()
// Set the state DB connection
dbConn, err := bundb.NewBunDBService(ctx, &state) dbConn, err := bundb.NewBunDBService(ctx, &state)
if err != nil { if err != nil {
return fmt.Errorf("error creating dbservice: %s", err) return nil, fmt.Errorf("error creating dbConn: %w", err)
} }
// Set the state DB connection
state.DB = dbConn state.DB = dbConn
username := config.GetAdminAccountUsername() return &state, nil
if username == "" { }
return errors.New("no username set")
func stopState(ctx context.Context, state *state.State) error {
if err := state.DB.Stop(ctx); err != nil {
return fmt.Errorf("error stopping dbConn: %w", err)
} }
state.Workers.Stop()
state.Caches.Stop()
return nil
}
// Create creates a new account and user
// in the database using the provided flags.
var Create action.GTSAction = func(ctx context.Context) error {
state, err := initState(ctx)
if err != nil {
return err
}
username := config.GetAdminAccountUsername()
if err := validate.Username(username); err != nil { if err := validate.Username(username); err != nil {
return err return err
} }
usernameAvailable, err := dbConn.IsUsernameAvailable(ctx, username) usernameAvailable, err := state.DB.IsUsernameAvailable(ctx, username)
if err != nil { if err != nil {
return err return err
} }
if !usernameAvailable { if !usernameAvailable {
return fmt.Errorf("username %s is already in use", username) return fmt.Errorf("username %s is already in use", username)
} }
email := config.GetAdminAccountEmail() email := config.GetAdminAccountEmail()
if email == "" {
return errors.New("no email set")
}
if err := validate.Email(email); err != nil { if err := validate.Email(email); err != nil {
return err return err
} }
emailAvailable, err := dbConn.IsEmailAvailable(ctx, email) emailAvailable, err := state.DB.IsEmailAvailable(ctx, email)
if err != nil { if err != nil {
return err return err
} }
if !emailAvailable { if !emailAvailable {
return fmt.Errorf("email address %s is already in use", email) return fmt.Errorf("email address %s is already in use", email)
} }
password := config.GetAdminAccountPassword() password := config.GetAdminAccountPassword()
if password == "" { if err := validate.Password(password); err != nil {
return errors.New("no password set")
}
if err := validate.NewPassword(password); err != nil {
return err return err
} }
_, err = dbConn.NewSignup(ctx, username, "", false, email, password, nil, "", "", true, "", false) if _, err := state.DB.NewSignup(ctx, gtsmodel.NewSignup{
if err != nil { Username: username,
Email: email,
Password: password,
EmailVerified: true, // Assume cli user wants email marked as verified already.
PreApproved: true, // Assume cli user wants account marked as approved already.
}); err != nil {
return err return err
} }
return dbConn.Stop(ctx) return stopState(ctx, state)
} }
// List returns all existing local accounts. // List returns all existing local accounts.
var List action.GTSAction = func(ctx context.Context) error { var List action.GTSAction = func(ctx context.Context) error {
var state state.State state, err := initState(ctx)
state.Caches.Init()
state.Workers.Start()
dbConn, err := bundb.NewBunDBService(ctx, &state)
if err != nil { if err != nil {
return fmt.Errorf("error creating dbservice: %s", err) return err
} }
// Set the state DB connection users, err := state.DB.GetAllUsers(ctx)
state.DB = dbConn
users, err := dbConn.GetAllUsers(ctx)
if err != nil { if err != nil {
return err return err
} }
@ -140,218 +152,182 @@
return nil return nil
} }
// Confirm sets a user to Approved, sets Email to the current UnconfirmedEmail value, and sets ConfirmedAt to now. // Confirm sets a user to Approved, sets Email to the current
// UnconfirmedEmail value, and sets ConfirmedAt to now.
var Confirm action.GTSAction = func(ctx context.Context) error { var Confirm action.GTSAction = func(ctx context.Context) error {
var state state.State state, err := initState(ctx)
state.Caches.Init()
state.Workers.Start()
dbConn, err := bundb.NewBunDBService(ctx, &state)
if err != nil { if err != nil {
return fmt.Errorf("error creating dbservice: %s", err) return err
} }
// Set the state DB connection
state.DB = dbConn
username := config.GetAdminAccountUsername() username := config.GetAdminAccountUsername()
if username == "" {
return errors.New("no username set")
}
if err := validate.Username(username); err != nil { if err := validate.Username(username); err != nil {
return err return err
} }
a, err := dbConn.GetAccountByUsernameDomain(ctx, username, "") account, err := state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil { if err != nil {
return err return err
} }
u, err := dbConn.GetUserByAccountID(ctx, a.ID) user, err := state.DB.GetUserByAccountID(ctx, account.ID)
if err != nil { if err != nil {
return err return err
} }
updatingColumns := []string{"approved", "email", "confirmed_at"} user.Approved = func() *bool { a := true; return &a }()
approved := true user.Email = user.UnconfirmedEmail
u.Approved = &approved user.ConfirmedAt = time.Now()
u.Email = u.UnconfirmedEmail if err := state.DB.UpdateUser(
u.ConfirmedAt = time.Now() ctx, user,
if err := dbConn.UpdateUser(ctx, u, updatingColumns...); err != nil { "approved", "email", "confirmed_at",
); err != nil {
return err return err
} }
return dbConn.Stop(ctx) return stopState(ctx, state)
} }
// Promote sets a user to admin. // Promote sets admin + moderator flags on a user to true.
var Promote action.GTSAction = func(ctx context.Context) error { var Promote action.GTSAction = func(ctx context.Context) error {
var state state.State state, err := initState(ctx)
state.Caches.Init()
state.Workers.Start()
dbConn, err := bundb.NewBunDBService(ctx, &state)
if err != nil { if err != nil {
return fmt.Errorf("error creating dbservice: %s", err) return err
} }
// Set the state DB connection
state.DB = dbConn
username := config.GetAdminAccountUsername() username := config.GetAdminAccountUsername()
if username == "" {
return errors.New("no username set")
}
if err := validate.Username(username); err != nil { if err := validate.Username(username); err != nil {
return err return err
} }
a, err := dbConn.GetAccountByUsernameDomain(ctx, username, "") account, err := state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil { if err != nil {
return err return err
} }
u, err := dbConn.GetUserByAccountID(ctx, a.ID) user, err := state.DB.GetUserByAccountID(ctx, account.ID)
if err != nil { if err != nil {
return err return err
} }
admin := true user.Admin = func() *bool { a := true; return &a }()
u.Admin = &admin user.Moderator = func() *bool { a := true; return &a }()
if err := dbConn.UpdateUser(ctx, u, "admin"); err != nil { if err := state.DB.UpdateUser(
ctx, user,
"admin", "moderator",
); err != nil {
return err return err
} }
return dbConn.Stop(ctx) return stopState(ctx, state)
} }
// Demote sets admin on a user to false. // Demote sets admin + moderator flags on a user to false.
var Demote action.GTSAction = func(ctx context.Context) error { var Demote action.GTSAction = func(ctx context.Context) error {
var state state.State state, err := initState(ctx)
state.Caches.Init()
state.Workers.Start()
dbConn, err := bundb.NewBunDBService(ctx, &state)
if err != nil { if err != nil {
return fmt.Errorf("error creating dbservice: %s", err) return err
} }
// Set the state DB connection
state.DB = dbConn
username := config.GetAdminAccountUsername() username := config.GetAdminAccountUsername()
if username == "" {
return errors.New("no username set")
}
if err := validate.Username(username); err != nil { if err := validate.Username(username); err != nil {
return err return err
} }
a, err := dbConn.GetAccountByUsernameDomain(ctx, username, "") a, err := state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil { if err != nil {
return err return err
} }
u, err := dbConn.GetUserByAccountID(ctx, a.ID) user, err := state.DB.GetUserByAccountID(ctx, a.ID)
if err != nil { if err != nil {
return err return err
} }
admin := false user.Admin = func() *bool { a := false; return &a }()
u.Admin = &admin user.Moderator = func() *bool { a := false; return &a }()
if err := dbConn.UpdateUser(ctx, u, "admin"); err != nil { if err := state.DB.UpdateUser(
ctx, user,
"admin", "moderator",
); err != nil {
return err return err
} }
return dbConn.Stop(ctx) return stopState(ctx, state)
} }
// Disable sets Disabled to true on a user. // Disable sets Disabled to true on a user.
var Disable action.GTSAction = func(ctx context.Context) error { var Disable action.GTSAction = func(ctx context.Context) error {
var state state.State state, err := initState(ctx)
state.Caches.Init()
state.Workers.Start()
dbConn, err := bundb.NewBunDBService(ctx, &state)
if err != nil { if err != nil {
return fmt.Errorf("error creating dbservice: %s", err) return err
} }
// Set the state DB connection
state.DB = dbConn
username := config.GetAdminAccountUsername() username := config.GetAdminAccountUsername()
if username == "" {
return errors.New("no username set")
}
if err := validate.Username(username); err != nil { if err := validate.Username(username); err != nil {
return err return err
} }
a, err := dbConn.GetAccountByUsernameDomain(ctx, username, "") account, err := state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil { if err != nil {
return err return err
} }
u, err := dbConn.GetUserByAccountID(ctx, a.ID) user, err := state.DB.GetUserByAccountID(ctx, account.ID)
if err != nil { if err != nil {
return err return err
} }
disabled := true user.Disabled = func() *bool { d := true; return &d }()
u.Disabled = &disabled if err := state.DB.UpdateUser(
if err := dbConn.UpdateUser(ctx, u, "disabled"); err != nil { ctx, user,
"disabled",
); err != nil {
return err return err
} }
return dbConn.Stop(ctx) return stopState(ctx, state)
} }
// Password sets the password of target account. // Password sets the password of target account.
var Password action.GTSAction = func(ctx context.Context) error { var Password action.GTSAction = func(ctx context.Context) error {
var state state.State state, err := initState(ctx)
state.Caches.Init()
state.Workers.Start()
dbConn, err := bundb.NewBunDBService(ctx, &state)
if err != nil { if err != nil {
return fmt.Errorf("error creating dbservice: %s", err) return err
} }
// Set the state DB connection
state.DB = dbConn
username := config.GetAdminAccountUsername() username := config.GetAdminAccountUsername()
if username == "" {
return errors.New("no username set")
}
if err := validate.Username(username); err != nil { if err := validate.Username(username); err != nil {
return err return err
} }
password := config.GetAdminAccountPassword() password := config.GetAdminAccountPassword()
if password == "" { if err := validate.Password(password); err != nil {
return errors.New("no password set")
}
if err := validate.NewPassword(password); err != nil {
return err return err
} }
a, err := dbConn.GetAccountByUsernameDomain(ctx, username, "") account, err := state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil { if err != nil {
return err return err
} }
u, err := dbConn.GetUserByAccountID(ctx, a.ID) user, err := state.DB.GetUserByAccountID(ctx, account.ID)
if err != nil { if err != nil {
return err return err
} }
pw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) encryptedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return fmt.Errorf("error hashing password: %s", err) return fmt.Errorf("error hashing password: %s", err)
} }
u.EncryptedPassword = string(pw) user.EncryptedPassword = string(encryptedPassword)
return dbConn.UpdateUser(ctx, u, "encrypted_password") if err := state.DB.UpdateUser(
ctx, user,
"encrypted_password",
); err != nil {
return err
}
return stopState(ctx, state)
} }

View file

@ -272,50 +272,89 @@ 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) { func (m *Module) createUserFromOIDC(ctx context.Context, claims *oidc.Claims, extraInfo *extraInfo, ip net.IP, appID string) (*gtsmodel.User, gtserror.WithCode) {
// check if the email address is available for use; if it's not there's nothing we can so // Check if the claimed email address is available for use.
emailAvailable, err := m.db.IsEmailAvailable(ctx, claims.Email) emailAvailable, err := m.db.IsEmailAvailable(ctx, claims.Email)
if err != nil { if err != nil {
return nil, gtserror.NewErrorBadRequest(err) err := gtserror.Newf("db error checking email availability: %w", err)
return nil, gtserror.NewErrorInternalError(err)
} }
if !emailAvailable { if !emailAvailable {
help := "The email address given to us by your authentication provider already exists in our records and the server administrator has not enabled account migration" const help = "The email address given to us by your authentication provider already exists in our records and the server administrator has not enabled account migration"
return nil, gtserror.NewErrorConflict(fmt.Errorf("email address %s is not available", claims.Email), help) err := gtserror.Newf("email address %s is not available", claims.Email)
return nil, gtserror.NewErrorConflict(err, help)
} }
// check if the user is in any recognised admin groups // We still need to set something as a password, even
adminGroups := config.GetOIDCAdminGroups() // if it's not a password the user will end up using.
var admin bool
LOOP:
for _, g := range claims.Groups {
for _, ag := range adminGroups {
if strings.EqualFold(g, ag) {
admin = true
break LOOP
}
}
}
// We still need to set *a* password even if it's not a password the user will end up using, so set something random.
// We'll just set two uuids on top of each other, which should be long + random enough to baffle any attempts to crack.
// //
// If the user ever wants to log in using gts password rather than oidc flow, they'll have to request a password reset, which is fine // We'll just set two uuids on top of each other, which
// should be long + random enough to baffle any attempts
// to crack, and which is also, conveniently, 72 bytes,
// which is the maximum length that bcrypt can handle.
//
// If the user ever wants to log in using a password
// rather than oidc flow, they'll have to request a
// password reset, which is fine.
password := uuid.NewString() + uuid.NewString() password := uuid.NewString() + uuid.NewString()
// Since this user is created via oidc, which has been set up by the admin, we can assume that the account is already // Since this user is created via OIDC, we can assume
// implicitly approved, and that the email address has already been verified: otherwise, we end up in situations where // that the account should be preapproved, and the email
// the admin first approves the user in OIDC, and then has to approve them again in GoToSocial, which doesn't make sense. // address should be considered as verified already,
// since the OIDC login was successful.
// //
// In other words, if a user logs in via OIDC, they should be able to use their account straight away. // If we don't assume this, we end up in a situation
// where the admin first adds a user to OIDC, then has
// to approve them again in GoToSocial when they log in
// there for the first time, which doesn't make sense.
// //
// See: https://github.com/superseriousbusiness/gotosocial/issues/357 // In other words, if a user logs in via OIDC, they
requireApproval := false // should be able to use their account straight away.
emailVerified := true var (
preApproved = true
emailVerified = true
)
// create the user! this will also create an account and store it in the database so we don't need to do that here // If one of the claimed groups corresponds to one of
user, err := m.db.NewSignup(ctx, extraInfo.Username, "", requireApproval, claims.Email, password, ip, "", appID, emailVerified, claims.Sub, admin) // the configured admin OIDC groups, create this user
// as an admin.
admin := adminGroup(claims.Groups)
// 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{
Username: extraInfo.Username,
Email: claims.Email,
Password: password,
SignUpIP: ip,
AppID: appID,
ExternalID: claims.Sub,
PreApproved: preApproved,
EmailVerified: emailVerified,
Admin: admin,
})
if err != nil { if err != nil {
err := gtserror.Newf("db error doing new signup: %w", err)
return nil, gtserror.NewErrorInternalError(err) return nil, gtserror.NewErrorInternalError(err)
} }
return user, nil return user, nil
} }
// adminGroup returns true if one of the given OIDC
// groups is equal to at least one admin OIDC group.
func adminGroup(groups []string) bool {
for _, ag := range config.GetOIDCAdminGroups() {
for _, g := range groups {
if strings.EqualFold(ag, g) {
// This is an admin group,
// ∴ user is an admin.
return true
}
}
}
// User is in no admin groups,
// ∴ user is not an admin.
return false
}

View file

@ -129,7 +129,7 @@ func validateCreateAccount(form *apimodel.AccountCreateRequest) error {
return err return err
} }
if err := validate.NewPassword(form.Password); err != nil { if err := validate.Password(form.Password); err != nil {
return err return err
} }

View file

@ -19,7 +19,6 @@
import ( import (
"context" "context"
"net"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
) )
@ -39,7 +38,7 @@ type Admin interface {
// NewSignup creates a new user in the database with the given parameters. // NewSignup creates a new user in the database with the given parameters.
// By the time this function is called, it should be assumed that all the parameters have passed validation! // By the time this function is called, it should be assumed that all the parameters have passed validation!
NewSignup(ctx context.Context, username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string, emailVerified bool, externalID string, admin bool) (*gtsmodel.User, Error) NewSignup(ctx context.Context, newSignup gtsmodel.NewSignup) (*gtsmodel.User, Error)
// CreateInstanceAccount creates an account in the database with the same username as the instance host value. // CreateInstanceAccount creates an account in the database with the same username as the instance host value.
// Ie., if the instance is hosted at 'example.org' the instance user will have a username of 'example.org'. // Ie., if the instance is hosted at 'example.org' the instance user will have a username of 'example.org'.

View file

@ -21,8 +21,8 @@
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"errors"
"fmt" "fmt"
"net"
"net/mail" "net/mail"
"strings" "strings"
"time" "time"
@ -30,6 +30,7 @@
"github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
@ -89,106 +90,134 @@ func (a *adminDB) IsEmailAvailable(ctx context.Context, email string) (bool, db.
return a.conn.NotExists(ctx, q) return a.conn.NotExists(ctx, q)
} }
func (a *adminDB) NewSignup(ctx context.Context, username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string, emailVerified bool, externalID string, admin bool) (*gtsmodel.User, db.Error) { func (a *adminDB) NewSignup(ctx context.Context, newSignup gtsmodel.NewSignup) (*gtsmodel.User, db.Error) {
key, err := rsa.GenerateKey(rand.Reader, rsaKeyBits) // If something went wrong previously while doing a new
if err != nil { // sign up with this username, we might already have an
log.Errorf(ctx, "error creating new rsa key: %s", err) // account, so check first.
account, err := a.state.DB.GetAccountByUsernameDomain(ctx, newSignup.Username, "")
if err != nil && !errors.Is(err, db.ErrNoEntries) {
// Real error occurred.
err := gtserror.Newf("error checking for existing account: %w", err)
return nil, err return nil, err
} }
// if something went wrong while creating a user, we might already have an account, so check here first... // If we didn't yet have an account
acct := &gtsmodel.Account{} // with this username, create one now.
if err := a.conn. if account == nil {
NewSelect(). uris := uris.GenerateURIsForAccount(newSignup.Username)
Model(acct).
Where("? = ?", bun.Ident("account.username"), username).
Where("? IS NULL", bun.Ident("account.domain")).
Scan(ctx); err != nil {
err = a.conn.ProcessError(err)
if err != db.ErrNoEntries {
log.Errorf(ctx, "error checking for existing account: %s", err)
return nil, err
}
// if we have db.ErrNoEntries, we just don't have an
// account yet so create one before we proceed
accountURIs := uris.GenerateURIsForAccount(username)
accountID, err := id.NewRandomULID() accountID, err := id.NewRandomULID()
if err != nil { if err != nil {
err := gtserror.Newf("error creating new account id: %w", err)
return nil, err return nil, err
} }
acct = &gtsmodel.Account{ privKey, err := rsa.GenerateKey(rand.Reader, rsaKeyBits)
if err != nil {
err := gtserror.Newf("error creating new rsa private key: %w", err)
return nil, err
}
account = &gtsmodel.Account{
ID: accountID, ID: accountID,
Username: username, Username: newSignup.Username,
DisplayName: username, DisplayName: newSignup.Username,
Reason: reason, Reason: newSignup.Reason,
Privacy: gtsmodel.VisibilityDefault, Privacy: gtsmodel.VisibilityDefault,
URL: accountURIs.UserURL, URI: uris.UserURI,
PrivateKey: key, URL: uris.UserURL,
PublicKey: &key.PublicKey, InboxURI: uris.InboxURI,
PublicKeyURI: accountURIs.PublicKeyURI, OutboxURI: uris.OutboxURI,
FollowingURI: uris.FollowingURI,
FollowersURI: uris.FollowersURI,
FeaturedCollectionURI: uris.FeaturedCollectionURI,
ActorType: ap.ActorPerson, ActorType: ap.ActorPerson,
URI: accountURIs.UserURI, PrivateKey: privKey,
InboxURI: accountURIs.InboxURI, PublicKey: &privKey.PublicKey,
OutboxURI: accountURIs.OutboxURI, PublicKeyURI: uris.PublicKeyURI,
FollowersURI: accountURIs.FollowersURI,
FollowingURI: accountURIs.FollowingURI,
FeaturedCollectionURI: accountURIs.FeaturedCollectionURI,
} }
// insert the new account! // Insert the new account!
if err := a.state.DB.PutAccount(ctx, acct); err != nil { if err := a.state.DB.PutAccount(ctx, account); err != nil {
return nil, err return nil, err
} }
} }
// we either created or already had an account by now, // Created or already had an account.
// so proceed with creating a user for that account // Ensure user not already created.
user, err := a.state.DB.GetUserByAccountID(ctx, account.ID)
pw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil && !errors.Is(err, db.ErrNoEntries) {
if err != nil { // Real error occurred.
return nil, fmt.Errorf("error hashing password: %s", err) err := gtserror.Newf("error checking for existing user: %w", err)
return nil, err
} }
defer func() {
// Pin account to (new)
// user before returning.
user.Account = account
}()
if user != nil {
// Already had a user for this
// account, just return that.
return user, nil
}
// Had no user for this account, time to create one!
newUserID, err := id.NewRandomULID() newUserID, err := id.NewRandomULID()
if err != nil { if err != nil {
err := gtserror.Newf("error creating new user id: %w", err)
return nil, err return nil, err
} }
// if we don't require moderator approval, just pre-approve the user encryptedPassword, err := bcrypt.GenerateFromPassword(
approved := !requireApproval []byte(newSignup.Password),
u := &gtsmodel.User{ bcrypt.DefaultCost,
)
if err != nil {
err := gtserror.Newf("error hashing password: %w", err)
return nil, err
}
user = &gtsmodel.User{
ID: newUserID, ID: newUserID,
AccountID: acct.ID, AccountID: account.ID,
Account: acct, Account: account,
EncryptedPassword: string(pw), EncryptedPassword: string(encryptedPassword),
SignUpIP: signUpIP.To4(), SignUpIP: newSignup.SignUpIP.To4(),
Locale: locale, Locale: newSignup.Locale,
UnconfirmedEmail: email, UnconfirmedEmail: newSignup.Email,
CreatedByApplicationID: appID, CreatedByApplicationID: newSignup.AppID,
Approved: &approved, ExternalID: newSignup.ExternalID,
ExternalID: externalID,
} }
if emailVerified { if newSignup.EmailVerified {
u.ConfirmedAt = time.Now() // Mark given email as confirmed.
u.Email = email user.ConfirmedAt = time.Now()
user.Email = newSignup.Email
} }
if admin { trueBool := func() *bool { t := true; return &t }
admin := true
moderator := true if newSignup.Admin {
u.Admin = &admin // Make new user mod + admin.
u.Moderator = &moderator user.Moderator = trueBool()
user.Admin = trueBool()
} }
// insert the user! if newSignup.PreApproved {
if err := a.state.DB.PutUser(ctx, u); err != nil { // Mark new user as approved.
user.Approved = trueBool()
}
// Insert the user!
if err := a.state.DB.PutUser(ctx, user); err != nil {
err := gtserror.Newf("db error inserting user: %w", err)
return nil, err return nil, err
} }
return u, nil return user, nil
} }
func (a *adminDB) CreateInstanceAccount(ctx context.Context) db.Error { func (a *adminDB) CreateInstanceAccount(ctx context.Context) db.Error {

View file

@ -17,7 +17,10 @@
package gtsmodel package gtsmodel
import "time" import (
"net"
"time"
)
// AdminAccountAction models an action taken by an instance administrator on an account. // AdminAccountAction models an action taken by an instance administrator on an account.
type AdminAccountAction struct { type AdminAccountAction struct {
@ -45,3 +48,23 @@ type AdminAccountAction struct {
// AdminActionSuspend -- the account or application etc has been deleted. // AdminActionSuspend -- the account or application etc has been deleted.
AdminActionSuspend AdminActionType = "suspend" AdminActionSuspend AdminActionType = "suspend"
) )
// NewSignup models parameters for the creation
// of a new user + account on this instance.
//
// Aside from username, email, and password, it is
// fine to use zero values on fields of this struct.
type NewSignup struct {
Username string // Username of the new account.
Email string // Email address of the user.
Password string // Plaintext (not yet hashed) password for the user.
Reason string // Reason given by the user when submitting a sign up request (optional).
PreApproved bool // Mark the new user/account as preapproved (optional)
SignUpIP net.IP // IP address from which the sign up request occurred (optional).
Locale string // Locale code for the new account/user (optional).
AppID string // ID of the application used to create this account (optional).
EmailVerified bool // Mark submitted email address as already verified (optional).
ExternalID string // ID of this user in external OIDC system (optional).
Admin bool // Mark new user as an admin user (optional).
}

View file

@ -26,61 +26,73 @@
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/oauth2/v4" "github.com/superseriousbusiness/oauth2/v4"
) )
// Create processes the given form for creating a new account, returning an oauth token for that account if successful. // Create processes the given form for creating a new account,
func (p *Processor) Create(ctx context.Context, applicationToken oauth2.TokenInfo, application *gtsmodel.Application, form *apimodel.AccountCreateRequest) (*apimodel.Token, gtserror.WithCode) { // returning an oauth token for that account if successful.
//
// Fields on the form should have already been validated by the
// caller, before this function is called.
func (p *Processor) Create(
ctx context.Context,
appToken oauth2.TokenInfo,
app *gtsmodel.Application,
form *apimodel.AccountCreateRequest,
) (*apimodel.Token, gtserror.WithCode) {
emailAvailable, err := p.state.DB.IsEmailAvailable(ctx, form.Email) emailAvailable, err := p.state.DB.IsEmailAvailable(ctx, form.Email)
if err != nil { if err != nil {
return nil, gtserror.NewErrorBadRequest(err) err := fmt.Errorf("db error checking email availability: %w", err)
return nil, gtserror.NewErrorInternalError(err)
} }
if !emailAvailable { if !emailAvailable {
return nil, gtserror.NewErrorConflict(fmt.Errorf("email address %s is not available", form.Email)) err := fmt.Errorf("email address %s is not available", form.Email)
return nil, gtserror.NewErrorConflict(err, err.Error())
} }
usernameAvailable, err := p.state.DB.IsUsernameAvailable(ctx, form.Username) usernameAvailable, err := p.state.DB.IsUsernameAvailable(ctx, form.Username)
if err != nil { if err != nil {
return nil, gtserror.NewErrorBadRequest(err) err := fmt.Errorf("db error checking username availability: %w", err)
return nil, gtserror.NewErrorInternalError(err)
} }
if !usernameAvailable { if !usernameAvailable {
return nil, gtserror.NewErrorConflict(fmt.Errorf("username %s in use", form.Username)) err := fmt.Errorf("username %s is not available", form.Username)
return nil, gtserror.NewErrorConflict(err, err.Error())
} }
reasonRequired := config.GetAccountsReasonRequired() // Only store reason if one is required.
approvalRequired := config.GetAccountsApprovalRequired() var reason string
if config.GetAccountsReasonRequired() {
// don't store a reason if we don't require one reason = form.Reason
reason := form.Reason
if !reasonRequired {
reason = ""
} }
log.Trace(ctx, "creating new username and account") user, err := p.state.DB.NewSignup(ctx, gtsmodel.NewSignup{
user, err := p.state.DB.NewSignup(ctx, form.Username, text.SanitizePlaintext(reason), approvalRequired, form.Email, form.Password, form.IP, form.Locale, application.ID, false, "", false) Username: form.Username,
Email: form.Email,
Password: form.Password,
Reason: text.SanitizePlaintext(reason),
PreApproved: !config.GetAccountsApprovalRequired(), // Mark as approved if no approval required.
SignUpIP: form.IP,
Locale: form.Locale,
AppID: app.ID,
})
if err != nil { if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error creating new signup in the database: %s", err)) err := fmt.Errorf("db error creating new signup: %w", err)
return nil, gtserror.NewErrorInternalError(err)
} }
log.Tracef(ctx, "generating a token for user %s with account %s and application %s", user.ID, user.AccountID, application.ID) // Generate access token *before* doing side effects; we
accessToken, err := p.oauthServer.GenerateUserAccessToken(ctx, applicationToken, application.ClientSecret, user.ID) // don't want to process side effects if something borks.
accessToken, err := p.oauthServer.GenerateUserAccessToken(ctx, appToken, app.ClientSecret, user.ID)
if err != nil { if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error creating new access token for user %s: %s", user.ID, err)) err := fmt.Errorf("error creating new access token for user %s: %w", user.ID, err)
return nil, gtserror.NewErrorInternalError(err)
} }
if user.Account == nil { // There are side effects for creating a new account
a, err := p.state.DB.GetAccountByID(ctx, user.AccountID) // (confirmation emails etc), perform these async.
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error getting new account from the database: %s", err))
}
user.Account = a
}
// there are side effects for creating a new account (sending confirmation emails etc)
// so pass a message to the processor so that it can do it asynchronously
p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{
APObjectType: ap.ObjectProfile, APObjectType: ap.ObjectProfile,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,

View file

@ -28,22 +28,41 @@
// PasswordChange processes a password change request for the given user. // 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 { 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([]byte(user.EncryptedPassword), []byte(oldPassword)); err != nil {
err := gtserror.Newf("%w", err)
return gtserror.NewErrorUnauthorized(err, "old password was incorrect") return gtserror.NewErrorUnauthorized(err, "old password was incorrect")
} }
if err := validate.NewPassword(newPassword); err != nil { // Ensure new password is strong enough.
if err := validate.Password(newPassword); err != nil {
return gtserror.NewErrorBadRequest(err, err.Error()) return gtserror.NewErrorBadRequest(err, err.Error())
} }
newPasswordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) // Ensure new password is different from old password.
if err != nil { if newPassword == oldPassword {
return gtserror.NewErrorInternalError(err, "error hashing password") const help = "new password cannot be the same as previous password"
err := gtserror.New(help)
return gtserror.NewErrorBadRequest(err, help)
} }
user.EncryptedPassword = string(newPasswordHash) // Hash the new password.
encryptedPassword, err := bcrypt.GenerateFromPassword(
[]byte(newPassword),
bcrypt.DefaultCost,
)
if err != nil {
err := gtserror.Newf("%w", err)
return gtserror.NewErrorInternalError(err)
}
if err := p.state.DB.UpdateUser(ctx, user, "encrypted_password"); err != nil { // Set new password on user.
user.EncryptedPassword = string(encryptedPassword)
if err := p.state.DB.UpdateUser(
ctx, user,
"encrypted_password",
); err != nil {
err := gtserror.Newf("db error updating user: %w", err)
return gtserror.NewErrorInternalError(err) return gtserror.NewErrorInternalError(err)
} }

View file

@ -54,7 +54,7 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordIncorrectOld() {
user := suite.testUsers["local_account_1"] user := suite.testUsers["local_account_1"]
errWithCode := suite.user.PasswordChange(context.Background(), user, "ooooopsydoooopsy", "verygoodnewpassword") errWithCode := suite.user.PasswordChange(context.Background(), user, "ooooopsydoooopsy", "verygoodnewpassword")
suite.EqualError(errWithCode, "crypto/bcrypt: hashedPassword is not the hash of the given password") suite.EqualError(errWithCode, "PasswordChange: crypto/bcrypt: hashedPassword is not the hash of the given password")
suite.Equal(http.StatusUnauthorized, errWithCode.Code()) suite.Equal(http.StatusUnauthorized, errWithCode.Code())
suite.Equal("Unauthorized: old password was incorrect", errWithCode.Safe()) suite.Equal("Unauthorized: old password was incorrect", errWithCode.Safe())

View file

@ -46,9 +46,9 @@
maximumListTitleLength = 200 maximumListTitleLength = 200
) )
// NewPassword returns a helpful error if the given password // Password returns a helpful error if the given password
// is too short, too long, or not sufficiently strong. // is too short, too long, or not sufficiently strong.
func NewPassword(password string) error { func Password(password string) error {
// Ensure length is OK first. // Ensure length is OK first.
if pwLen := len(password); pwLen == 0 { if pwLen := len(password); pwLen == 0 {
return errors.New("no password provided / provided password was 0 bytes") return errors.New("no password provided / provided password was 0 bytes")

View file

@ -43,42 +43,42 @@ func (suite *ValidationTestSuite) TestCheckPasswordStrength() {
strongPassword := "3dX5@Zc%mV*W2MBNEy$@" strongPassword := "3dX5@Zc%mV*W2MBNEy$@"
var err error var err error
err = validate.NewPassword(empty) err = validate.Password(empty)
if suite.Error(err) { if suite.Error(err) {
suite.Equal(errors.New("no password provided / provided password was 0 bytes"), err) suite.Equal(errors.New("no password provided / provided password was 0 bytes"), err)
} }
err = validate.NewPassword(terriblePassword) err = validate.Password(terriblePassword)
if suite.Error(err) { if suite.Error(err) {
suite.Equal(errors.New("password is only 62% strength, try including more special characters, using uppercase letters, using numbers or using a longer password"), err) suite.Equal(errors.New("password is only 62% strength, try including more special characters, using uppercase letters, using numbers or using a longer password"), err)
} }
err = validate.NewPassword(weakPassword) err = validate.Password(weakPassword)
if suite.Error(err) { if suite.Error(err) {
suite.Equal(errors.New("password is only 95% strength, try including more special characters, using numbers or using a longer password"), err) suite.Equal(errors.New("password is only 95% strength, try including more special characters, using numbers or using a longer password"), err)
} }
err = validate.NewPassword(shortPassword) err = validate.Password(shortPassword)
if suite.Error(err) { if suite.Error(err) {
suite.Equal(errors.New("password is only 39% strength, try including more special characters or using a longer password"), err) suite.Equal(errors.New("password is only 39% strength, try including more special characters or using a longer password"), err)
} }
err = validate.NewPassword(specialPassword) err = validate.Password(specialPassword)
if suite.Error(err) { if suite.Error(err) {
suite.Equal(errors.New("password is only 53% strength, try including more special characters or using a longer password"), err) suite.Equal(errors.New("password is only 53% strength, try including more special characters or using a longer password"), err)
} }
err = validate.NewPassword(longPassword) err = validate.Password(longPassword)
if suite.NoError(err) { if suite.NoError(err) {
suite.Equal(nil, err) suite.Equal(nil, err)
} }
err = validate.NewPassword(tooLong) err = validate.Password(tooLong)
if suite.Error(err) { if suite.Error(err) {
suite.Equal(errors.New("password should be no more than 72 bytes, provided password was 571 bytes"), err) suite.Equal(errors.New("password should be no more than 72 bytes, provided password was 571 bytes"), err)
} }
err = validate.NewPassword(strongPassword) err = validate.Password(strongPassword)
if suite.NoError(err) { if suite.NoError(err) {
suite.Equal(nil, err) suite.Equal(nil, err)
} }