[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 @@ package account
import (
"context"
"errors"
"fmt"
"os"
"text/tabwriter"
@ -28,88 +27,101 @@ import (
"github.com/superseriousbusiness/gotosocial/cmd/gotosocial/action"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db/bundb"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/validate"
"golang.org/x/crypto/bcrypt"
)
// Create creates a new account in the database using the provided flags.
var Create action.GTSAction = func(ctx context.Context) error {
func initState(ctx context.Context) (*state.State, error) {
var state state.State
state.Caches.Init()
state.Caches.Start()
state.Workers.Start()
// Set the state DB connection
dbConn, err := bundb.NewBunDBService(ctx, &state)
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
username := config.GetAdminAccountUsername()
if username == "" {
return errors.New("no username set")
return &state, nil
}
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 {
return err
}
usernameAvailable, err := dbConn.IsUsernameAvailable(ctx, username)
usernameAvailable, err := state.DB.IsUsernameAvailable(ctx, username)
if err != nil {
return err
}
if !usernameAvailable {
return fmt.Errorf("username %s is already in use", username)
}
email := config.GetAdminAccountEmail()
if email == "" {
return errors.New("no email set")
}
if err := validate.Email(email); err != nil {
return err
}
emailAvailable, err := dbConn.IsEmailAvailable(ctx, email)
emailAvailable, err := state.DB.IsEmailAvailable(ctx, email)
if err != nil {
return err
}
if !emailAvailable {
return fmt.Errorf("email address %s is already in use", email)
}
password := config.GetAdminAccountPassword()
if password == "" {
return errors.New("no password set")
}
if err := validate.NewPassword(password); err != nil {
if err := validate.Password(password); err != nil {
return err
}
_, err = dbConn.NewSignup(ctx, username, "", false, email, password, nil, "", "", true, "", false)
if err != nil {
if _, err := state.DB.NewSignup(ctx, gtsmodel.NewSignup{
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 dbConn.Stop(ctx)
return stopState(ctx, state)
}
// List returns all existing local accounts.
var List action.GTSAction = func(ctx context.Context) error {
var state state.State
state.Caches.Init()
state.Workers.Start()
dbConn, err := bundb.NewBunDBService(ctx, &state)
state, err := initState(ctx)
if err != nil {
return fmt.Errorf("error creating dbservice: %s", err)
return err
}
// Set the state DB connection
state.DB = dbConn
users, err := dbConn.GetAllUsers(ctx)
users, err := state.DB.GetAllUsers(ctx)
if err != nil {
return err
}
@ -140,218 +152,182 @@ var List action.GTSAction = func(ctx context.Context) error {
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 state state.State
state.Caches.Init()
state.Workers.Start()
dbConn, err := bundb.NewBunDBService(ctx, &state)
state, err := initState(ctx)
if err != nil {
return fmt.Errorf("error creating dbservice: %s", err)
return err
}
// Set the state DB connection
state.DB = dbConn
username := config.GetAdminAccountUsername()
if username == "" {
return errors.New("no username set")
}
if err := validate.Username(username); err != nil {
return err
}
a, err := dbConn.GetAccountByUsernameDomain(ctx, username, "")
account, err := state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil {
return err
}
u, err := dbConn.GetUserByAccountID(ctx, a.ID)
user, err := state.DB.GetUserByAccountID(ctx, account.ID)
if err != nil {
return err
}
updatingColumns := []string{"approved", "email", "confirmed_at"}
approved := true
u.Approved = &approved
u.Email = u.UnconfirmedEmail
u.ConfirmedAt = time.Now()
if err := dbConn.UpdateUser(ctx, u, updatingColumns...); err != nil {
user.Approved = func() *bool { a := true; return &a }()
user.Email = user.UnconfirmedEmail
user.ConfirmedAt = time.Now()
if err := state.DB.UpdateUser(
ctx, user,
"approved", "email", "confirmed_at",
); err != nil {
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 state state.State
state.Caches.Init()
state.Workers.Start()
dbConn, err := bundb.NewBunDBService(ctx, &state)
state, err := initState(ctx)
if err != nil {
return fmt.Errorf("error creating dbservice: %s", err)
return err
}
// Set the state DB connection
state.DB = dbConn
username := config.GetAdminAccountUsername()
if username == "" {
return errors.New("no username set")
}
if err := validate.Username(username); err != nil {
return err
}
a, err := dbConn.GetAccountByUsernameDomain(ctx, username, "")
account, err := state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil {
return err
}
u, err := dbConn.GetUserByAccountID(ctx, a.ID)
user, err := state.DB.GetUserByAccountID(ctx, account.ID)
if err != nil {
return err
}
admin := true
u.Admin = &admin
if err := dbConn.UpdateUser(ctx, u, "admin"); err != nil {
user.Admin = func() *bool { a := true; return &a }()
user.Moderator = func() *bool { a := true; return &a }()
if err := state.DB.UpdateUser(
ctx, user,
"admin", "moderator",
); err != nil {
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 state state.State
state.Caches.Init()
state.Workers.Start()
dbConn, err := bundb.NewBunDBService(ctx, &state)
state, err := initState(ctx)
if err != nil {
return fmt.Errorf("error creating dbservice: %s", err)
return err
}
// Set the state DB connection
state.DB = dbConn
username := config.GetAdminAccountUsername()
if username == "" {
return errors.New("no username set")
}
if err := validate.Username(username); err != nil {
return err
}
a, err := dbConn.GetAccountByUsernameDomain(ctx, username, "")
a, err := state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil {
return err
}
u, err := dbConn.GetUserByAccountID(ctx, a.ID)
user, err := state.DB.GetUserByAccountID(ctx, a.ID)
if err != nil {
return err
}
admin := false
u.Admin = &admin
if err := dbConn.UpdateUser(ctx, u, "admin"); err != nil {
user.Admin = func() *bool { a := false; return &a }()
user.Moderator = func() *bool { a := false; return &a }()
if err := state.DB.UpdateUser(
ctx, user,
"admin", "moderator",
); err != nil {
return err
}
return dbConn.Stop(ctx)
return stopState(ctx, state)
}
// Disable sets Disabled to true on a user.
var Disable action.GTSAction = func(ctx context.Context) error {
var state state.State
state.Caches.Init()
state.Workers.Start()
dbConn, err := bundb.NewBunDBService(ctx, &state)
state, err := initState(ctx)
if err != nil {
return fmt.Errorf("error creating dbservice: %s", err)
return err
}
// Set the state DB connection
state.DB = dbConn
username := config.GetAdminAccountUsername()
if username == "" {
return errors.New("no username set")
}
if err := validate.Username(username); err != nil {
return err
}
a, err := dbConn.GetAccountByUsernameDomain(ctx, username, "")
account, err := state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil {
return err
}
u, err := dbConn.GetUserByAccountID(ctx, a.ID)
user, err := state.DB.GetUserByAccountID(ctx, account.ID)
if err != nil {
return err
}
disabled := true
u.Disabled = &disabled
if err := dbConn.UpdateUser(ctx, u, "disabled"); err != nil {
user.Disabled = func() *bool { d := true; return &d }()
if err := state.DB.UpdateUser(
ctx, user,
"disabled",
); err != nil {
return err
}
return dbConn.Stop(ctx)
return stopState(ctx, state)
}
// Password sets the password of target account.
var Password action.GTSAction = func(ctx context.Context) error {
var state state.State
state.Caches.Init()
state.Workers.Start()
dbConn, err := bundb.NewBunDBService(ctx, &state)
state, err := initState(ctx)
if err != nil {
return fmt.Errorf("error creating dbservice: %s", err)
return err
}
// Set the state DB connection
state.DB = dbConn
username := config.GetAdminAccountUsername()
if username == "" {
return errors.New("no username set")
}
if err := validate.Username(username); err != nil {
return err
}
password := config.GetAdminAccountPassword()
if password == "" {
return errors.New("no password set")
}
if err := validate.NewPassword(password); err != nil {
if err := validate.Password(password); err != nil {
return err
}
a, err := dbConn.GetAccountByUsernameDomain(ctx, username, "")
account, err := state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil {
return err
}
u, err := dbConn.GetUserByAccountID(ctx, a.ID)
user, err := state.DB.GetUserByAccountID(ctx, account.ID)
if err != nil {
return err
}
pw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
encryptedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("error hashing password: %s", err)
}
u.EncryptedPassword = string(pw)
return dbConn.UpdateUser(ctx, u, "encrypted_password")
user.EncryptedPassword = string(encryptedPassword)
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) {
// 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)
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 {
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)
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"
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
adminGroups := config.GetOIDCAdminGroups()
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.
// We still need to set something as a password, even
// if it's not a password the user will end up using.
//
// 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()
// Since this user is created via oidc, which has been set up by the admin, we can assume that the account is already
// implicitly approved, and that the email address has already been verified: otherwise, we end up in situations where
// the admin first approves the user in OIDC, and then has to approve them again in GoToSocial, which doesn't make sense.
// Since this user is created via OIDC, we can assume
// that the account should be preapproved, and the email
// 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
requireApproval := false
emailVerified := true
// In other words, if a user logs in via OIDC, they
// should be able to use their account straight away.
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
user, err := m.db.NewSignup(ctx, extraInfo.Username, "", requireApproval, claims.Email, password, ip, "", appID, emailVerified, claims.Sub, admin)
// If one of the claimed groups corresponds to one of
// 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 {
err := gtserror.Newf("db error doing new signup: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
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
}
if err := validate.NewPassword(form.Password); err != nil {
if err := validate.Password(form.Password); err != nil {
return err
}

View File

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

View File

@ -17,7 +17,10 @@
package gtsmodel
import "time"
import (
"net"
"time"
)
// AdminAccountAction models an action taken by an instance administrator on an account.
type AdminAccountAction struct {
@ -45,3 +48,23 @@ const (
// AdminActionSuspend -- the account or application etc has been deleted.
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 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/oauth2/v4"
)
// Create processes the given form for creating a new account, returning an oauth token for that account if successful.
func (p *Processor) Create(ctx context.Context, applicationToken oauth2.TokenInfo, application *gtsmodel.Application, form *apimodel.AccountCreateRequest) (*apimodel.Token, gtserror.WithCode) {
// Create processes the given form for creating a new account,
// 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)
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 {
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)
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 {
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()
approvalRequired := config.GetAccountsApprovalRequired()
// don't store a reason if we don't require one
reason := form.Reason
if !reasonRequired {
reason = ""
// Only store reason if one is required.
var reason string
if config.GetAccountsReasonRequired() {
reason = form.Reason
}
log.Trace(ctx, "creating new username and account")
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)
user, err := p.state.DB.NewSignup(ctx, gtsmodel.NewSignup{
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 {
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)
accessToken, err := p.oauthServer.GenerateUserAccessToken(ctx, applicationToken, application.ClientSecret, user.ID)
// Generate access token *before* doing side effects; we
// don't want to process side effects if something borks.
accessToken, err := p.oauthServer.GenerateUserAccessToken(ctx, appToken, app.ClientSecret, user.ID)
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 {
a, err := p.state.DB.GetAccountByID(ctx, user.AccountID)
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
// There are side effects for creating a new account
// (confirmation emails etc), perform these async.
p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{
APObjectType: ap.ObjectProfile,
APActivityType: ap.ActivityCreate,

View File

@ -28,22 +28,41 @@ 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 {
err := gtserror.Newf("%w", err)
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())
}
newPasswordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return gtserror.NewErrorInternalError(err, "error hashing password")
// Ensure new password is different from old password.
if newPassword == oldPassword {
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)
}

View File

@ -54,7 +54,7 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordIncorrectOld() {
user := suite.testUsers["local_account_1"]
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("Unauthorized: old password was incorrect", errWithCode.Safe())

View File

@ -46,9 +46,9 @@ const (
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.
func NewPassword(password string) error {
func Password(password string) error {
// Ensure length is OK first.
if pwLen := len(password); pwLen == 0 {
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$@"
var err error
err = validate.NewPassword(empty)
err = validate.Password(empty)
if suite.Error(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) {
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) {
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) {
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) {
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) {
suite.Equal(nil, err)
}
err = validate.NewPassword(tooLong)
err = validate.Password(tooLong)
if suite.Error(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) {
suite.Equal(nil, err)
}