Skip to main content
Glama
Southclaws

Storyden

by Southclaws
register.go17.1 kB
package register import ( "context" "log/slog" "net/mail" "github.com/Southclaws/fault" "github.com/Southclaws/fault/fctx" "github.com/Southclaws/fault/fmsg" "github.com/Southclaws/opt" petname "github.com/dustinkirkland/golang-petname" "github.com/samber/lo" "github.com/Southclaws/storyden/app/resources/account" "github.com/Southclaws/storyden/app/resources/account/account_querier" "github.com/Southclaws/storyden/app/resources/account/account_writer" "github.com/Southclaws/storyden/app/resources/account/authentication" "github.com/Southclaws/storyden/app/resources/account/email" "github.com/Southclaws/storyden/app/resources/mark" "github.com/Southclaws/storyden/app/resources/message" "github.com/Southclaws/storyden/app/services/authentication/email_verify" "github.com/Southclaws/storyden/app/services/authentication/session" "github.com/Southclaws/storyden/app/services/onboarding" "github.com/Southclaws/storyden/internal/infrastructure/pubsub" "github.com/Southclaws/storyden/internal/otp" ) var ( errEmailAlreadyRegistered = fault.New("email already registered") errAccountMismatch = fault.New("account mismatch") errEmailNotVerified = fault.New("email not verified") errAuthMethodAlreadyLinked = fault.New("authentication method already linked to another account") ) type Registrar struct { logger *slog.Logger accountWriter *account_writer.Writer accountQuerier *account_querier.Querier emailRepo *email.Repository emailVerify *email_verify.Verifier authRepo authentication.Repository onboarding onboarding.Service bus *pubsub.Bus } func New( logger *slog.Logger, writer *account_writer.Writer, accountQuerier *account_querier.Querier, emailRepo *email.Repository, emailVerify *email_verify.Verifier, authRepo authentication.Repository, onboarding onboarding.Service, bus *pubsub.Bus, ) *Registrar { return &Registrar{ logger: logger, accountWriter: writer, accountQuerier: accountQuerier, emailRepo: emailRepo, emailVerify: emailVerify, authRepo: authRepo, onboarding: onboarding, bus: bus, } } func (s *Registrar) Create(ctx context.Context, handle opt.Optional[string], opts ...account_writer.Option) (*account.Account, error) { status, err := s.onboarding.GetOnboardingStatus(ctx) if err != nil { return nil, fault.Wrap(err, fctx.With(ctx), fmsg.WithDesc("failed to check onboarding status", "Unable to verify system setup. Please try again or contact site administration.")) } if status == &onboarding.StatusRequiresFirstAccount { // If we're doing first-time-setup then set the first account to admin. opts = append(opts, account_writer.WithAdmin(true)) } // If no handle was given, generate one using adjective-animal. handleOrGenerated := handle.Or(petname.Generate(2, "-")) if err := account.ValidateHandle(ctx, handleOrGenerated); err != nil { return nil, err } acc, err := s.accountWriter.Create(ctx, handleOrGenerated, opts...) if err != nil { return nil, fault.Wrap(err, fctx.With(ctx), fmsg.WithDesc("failed to create account", "Unable to create your account.")) } s.bus.Publish(ctx, &message.EventAccountCreated{ ID: acc.Account.ID, }) return &acc.Account, nil } // GetOrCreateViaEmail is intended to be used for just OAuth2 providers. It will // cover cases for existing auth records, existing emails and ensure accounts // are linked correctly if necessary and also ensure mismatches are handled. func (s *Registrar) GetOrCreateViaEmail( ctx context.Context, service authentication.Service, authName string, identifier string, token string, handle string, name string, email mail.Address, ) (*account.Account, error) { // Two key pieces of information here can point to an existing account. The // authentication record and the email address. In most cases, either both // will exist (a login) or neither will exist (a registration). However, in // some cases, a member may already have an account but not have an email. // Or somehow, their email is associated with a different account to the one // that their auth record points to. This function handles all these cases. // A session will be present if the user is attempting to link an account // to their Storyden account, rather than registering or logging in. session := session.GetOptAccount(ctx) authmethod, authMethodExists, err := s.authRepo.LookupByIdentifier(ctx, service, identifier) if err != nil { return nil, fault.Wrap(err, fmsg.With("failed to lookup existing account"), fctx.With(ctx)) } // For logged-in accounts, ensure the auth method being used is not already // linked to a different account. if sessionAccount, ok := session.Get(); ok && authMethodExists { if sessionAccount.ID != authmethod.Account.ID { return nil, fault.Wrap(errAccountMismatch, fctx.With(ctx), fmsg.WithDesc("account mismatch", "This authentication method is linked to a different account.")) } } emailOwner, emailExists, err := s.emailRepo.LookupAccount(ctx, email) if err != nil { return nil, fault.Wrap(err, fctx.With(ctx), fmsg.WithDesc("failed to lookup email address", "Unable to check if this email is already registered. Please try again.")) } isVerified := func() bool { if !emailExists { return false } current, found := lo.Find(emailOwner.EmailAddresses, func(e *account.EmailAddress) bool { return e.Email.Address == email.Address }) if !found { return false } return current.Verified }() // Normalize handle to slug format. We don't error here because the handle // is provided by an external provider, so it's not necessarily always in // the end-user's control. Erroring here would result in a dead-end UX flow. handle = mark.Slugify(handle) logger := s.logger.With( slog.String("handle", handle), slog.String("name", name), slog.String("email", email.Address), slog.Bool("auth_method_exists", authMethodExists), slog.Bool("email_exists", emailExists), slog.Bool("email_verified", isVerified), ) switch { case authMethodExists && emailExists: // Member has already registered with this email address and the // same email exists and points to an account. Verify those accounts // are the same, if so, return the account. If not, bad state, error. if authmethod.Account.ID != emailOwner.ID { return nil, fault.Wrap(errEmailAlreadyRegistered, fctx.With(ctx), fmsg.WithDesc("email already in use by another account", "Unable to complete sign-in. Please contact support if this issue persists."), ) } if sessionAccount, ok := session.Get(); ok { if sessionAccount.ID != emailOwner.ID { return nil, fault.Wrap(errAccountMismatch, fctx.With(ctx), fmsg.WithDesc("account mismatch", "This authentication method is linked to a different account.")) } } logger.Info("get or create: account already exists") return &emailOwner.Account, nil case authMethodExists && !emailExists: // Member has already registered with this authentication method but // the email address associated with the authentication method has // not yet been recorded. This may happen if certain auth providers // are enabled at a later date, such as with OAuth providers. // Link the email address to the account and send a verification. err = s.linkAndVerifyEmail(ctx, authmethod.Account.ID, email) if err != nil { return nil, fault.Wrap(err, fctx.With(ctx), fmsg.WithDesc("failed to link and verify email", "Unable to link email address to your account. Please try again.")) } logger.Info("get or create: auth method exists, email not recorded, linking new email to existing account") return &authmethod.Account, nil case !authMethodExists && emailExists: // Member has already registered with this email address, perhaps on // a different auth provider. We know it's the same email assuming // the caller has verified this via OAuth or similar. The email may // have also been added manually via a newsletter list. Link them. if !isVerified { return nil, fault.Wrap(errEmailNotVerified, fctx.With(ctx), fmsg.WithDesc("email not verified", "Unable to complete sign-in. Please contact support if this issue persists."), ) } _, err = s.authRepo.Create(ctx, emailOwner.ID, service, authentication.TokenTypeOAuth, identifier, token, nil, authentication.WithName(authName)) if err != nil { return nil, fault.Wrap(err, fmsg.With("failed to create new auth method for existing email"), fctx.With(ctx)) } logger.Info("get or create: no auth record, email already points to existing account, linking new auth method to existing account") return &emailOwner.Account, nil case !authMethodExists && !emailExists: // Nothing exists for this member yet, create a new account. newAccount, err := s.CreateWithHandle(ctx, service, authName, identifier, token, name, handle) if err != nil { return nil, fault.Wrap(err, fmsg.With("failed to create new account"), fctx.With(ctx)) } if !isVerified { err = s.linkAndVerifyEmail(ctx, newAccount.ID, email) if err != nil { return nil, fault.Wrap(err, fctx.With(ctx), fmsg.WithDesc("failed to link and verify email", "Unable to send verification email. Please try again or contact site administration.")) } } logger.Info("get or create: no auth record, no email record, creating new account and verifying email") return newAccount, nil default: // switch block covers all cases. panic("unreachable") } } func (s *Registrar) GetOrCreateViaHandle( ctx context.Context, service authentication.Service, authName string, identifier string, token string, handle string, name string, ) (*account.Account, error) { // A session will be present if the user is attempting to link an account // to their Storyden account, rather than registering or logging in. session := session.GetOptAccount(ctx) authmethod, authMethodExists, err := s.authRepo.LookupByIdentifier(ctx, service, identifier) if err != nil { return nil, fault.Wrap(err, fmsg.With("failed to lookup existing account"), fctx.With(ctx)) } if sessionAccount, ok := session.Get(); ok && authMethodExists { if sessionAccount.ID != authmethod.Account.ID { return nil, fault.Wrap(errAccountMismatch, fctx.With(ctx), fmsg.WithDesc("account mismatch", "This authentication method is linked to a different account.")) } } handleOwner, handleExists, err := s.accountQuerier.LookupByHandle(ctx, handle) if err != nil { return nil, fault.Wrap(err, fctx.With(ctx), fmsg.WithDesc("failed to lookup username", "Unable to check if this username is already registered. Please try again.")) } logger := s.logger.With( slog.String("handle", handle), slog.String("name", name), slog.Bool("auth_method_exists", authMethodExists), slog.Bool("handle_exists", handleExists), ) switch { case authMethodExists && handleExists: // Member has already registered with this handle and the same handle // exists and points to an account. Verify those accounts are the same. if authmethod.Account.ID != handleOwner.ID { // If the authentication method looked up via the OAuth provider // identifier exists, we have an account and it's verified to be // owned by someone due to the OAuth process. The handle passed in // is purely for registration purposes fetched from the provider, // but it's common for handles to change on both Storyden itself and // OAuth services, so we don't use the handle for matching only the // OAuth identifier. If they don't match, no problem, just return // the account linked to the auth method. logger.Info("get or create: different account already exists with handle, returning existing auth method linked account") return &authmethod.Account, nil } logger.Info("get or create: account already exists with handle") return &handleOwner.Account, nil case authMethodExists && !handleExists: // Member has already registered with this authentication method but // has changed their name, this is fine. Return the existing account. logger.Info("get or create: auth method exists, but account handle changed") return &authmethod.Account, nil case !authMethodExists && handleExists: // Member has already registered with this handle, we can only verify // ownership if there's an existing session, if not, create new account. if sessionAccount, ok := session.Get(); ok { _, err = s.authRepo.Create(ctx, sessionAccount.ID, service, authentication.TokenTypeOAuth, identifier, token, nil, authentication.WithName(authName)) if err != nil { return nil, fault.Wrap(err, fmsg.With("failed to create new auth method for existing already logged-in account with same handle"), fctx.With(ctx)) } return &sessionAccount, nil } logger.Info("get or create: no auth record, handle already points to existing account, creating new account with random handle") return s.CreateWithRandomHandle(ctx, service, authName, identifier, token, name) case !authMethodExists && !handleExists: // Nothing exists for this member yet, create a new account. logger.Info("get or create: no auth record, no email record, creating new account and verifying email") if sessionAccount, ok := session.Get(); ok { // They're already logged in but trying to link a new account. The // handle has not been registered yet so we can assume the handle // provided by the OAuth provider is different to the one they are // already using on Storyden. Link the auth method and return acc. _, err = s.authRepo.Create(ctx, sessionAccount.ID, service, authentication.TokenTypeOAuth, identifier, token, nil, authentication.WithName(authName)) if err != nil { return nil, fault.Wrap(err, fmsg.With("failed to create new auth method for existing already logged-in account"), fctx.With(ctx)) } return &sessionAccount, nil } return s.CreateWithHandle(ctx, service, authName, identifier, token, name, handle) default: // switch block covers all cases. panic("unreachable") } } func (s *Registrar) CreateWithRandomHandle( ctx context.Context, service authentication.Service, authName string, identifier string, token string, name string, ) (*account.Account, error) { _, authMethodExists, err := s.authRepo.LookupByIdentifier(ctx, service, identifier) if err != nil { return nil, fault.Wrap(err, fmsg.With("failed to lookup existing account"), fctx.With(ctx)) } if authMethodExists { return nil, fault.Wrap(errAuthMethodAlreadyLinked, fctx.With(ctx), fmsg.WithDesc("authMethodExists", "This authentication provider has already been linked to another account."), ) } randomHandle := petname.Generate(3, "-") newAccount, err := s.Create(ctx, opt.New(randomHandle), account_writer.WithName(name)) if err != nil { return nil, fault.Wrap(err, fmsg.With("failed to create new account"), fctx.With(ctx)) } _, err = s.authRepo.Create(ctx, newAccount.ID, service, authentication.TokenTypeOAuth, identifier, token, nil, authentication.WithName(authName)) if err != nil { return nil, fault.Wrap(err, fmsg.With("failed to create new auth method for brand new account with random handle"), fctx.With(ctx)) } return newAccount, nil } func (s *Registrar) CreateWithHandle( ctx context.Context, service authentication.Service, authName string, identifier string, token string, name string, handle string, ) (*account.Account, error) { _, authMethodExists, err := s.authRepo.LookupByIdentifier(ctx, service, identifier) if err != nil { return nil, fault.Wrap(err, fmsg.With("failed to lookup existing account"), fctx.With(ctx)) } if authMethodExists { return nil, fault.Wrap(errAuthMethodAlreadyLinked, fctx.With(ctx), fmsg.WithDesc("authMethodExists", "This authentication provider has already been linked to another account."), ) } newAccount, err := s.Create(ctx, opt.New(handle), account_writer.WithName(name)) if err != nil { return nil, fault.Wrap(err, fmsg.With("failed to create new account"), fctx.With(ctx)) } _, err = s.authRepo.Create(ctx, newAccount.ID, service, authentication.TokenTypeOAuth, identifier, token, nil, authentication.WithName(authName)) if err != nil { return nil, fault.Wrap(err, fmsg.With("failed to create new auth method for brand new account"), fctx.With(ctx)) } return newAccount, nil } func (s *Registrar) linkAndVerifyEmail(ctx context.Context, accID account.AccountID, email mail.Address) error { code, err := otp.Generate() if err != nil { return fault.Wrap(err, fctx.With(ctx), fmsg.WithDesc("failed to generate verification code", "Unable to create verification code. Please try again.")) } _, err = s.emailVerify.BeginEmailVerification(ctx, accID, email, code) if err != nil { return fault.Wrap(err, fctx.With(ctx), fmsg.WithDesc("failed to begin email verification", "Unable to send verification email. Please try again or contact site administration.")) } return nil }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Southclaws/storyden'

If you have feedback or need assistance with the MCP directory API, please join our Discord server