From 12ed7fb5bbe815cf106119bd223e7cbeee0febbc Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 13 Feb 2024 16:57:03 +0100 Subject: [PATCH] feat: add POST /api/v2/auth/email/signup/confirm --- backend/db/email.go | 33 +++-- backend/db/user.go | 20 ++- backend/routes/v2/auth/email_signup.go | 134 ++++++++++++++++++- backend/routes/v2/auth/get_emails.go | 10 +- backend/routes/v2/auth/routes.go | 10 +- backend/routes/v2/auth/send_email.go | 42 +++++- backend/routes/v2/auth/templates/signup.html | 16 +++ backend/routes/v2/auth/templates/signup.txt | 4 + go.mod | 6 +- go.sum | 14 +- scripts/migrate/023_email.sql | 4 +- 11 files changed, 255 insertions(+), 38 deletions(-) create mode 100644 backend/routes/v2/auth/templates/signup.html diff --git a/backend/db/email.go b/backend/db/email.go index 593d9ad..8525ddd 100644 --- a/backend/db/email.go +++ b/backend/db/email.go @@ -2,6 +2,8 @@ package db import ( "context" + "crypto/rand" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" @@ -14,9 +16,6 @@ type UserEmail struct { ID common.EmailID UserID common.UserID EmailAddress string - Confirmed bool - - ConfirmationToken *string } func (db *DB) UserEmails(ctx context.Context, userID common.UserID) (es []UserEmail, err error) { @@ -34,13 +33,11 @@ func (db *DB) UserEmails(ctx context.Context, userID common.UserID) (es []UserEm } // UserByEmail gets a user by their email address. -// The email address must be confirmed. func (db *DB) UserByEmail(ctx context.Context, email string) (u User, err error) { sql, args, err := sq.Select("users.*"). From("user_emails"). Join("users ON user_emails.user_id = users.snowflake_id"). Where("email_address = ?", email). - Where("confirmed = ?", true). ToSql() if err != nil { return u, errors.Wrap(err, "building query") @@ -67,11 +64,9 @@ const ErrEmailInUse = errors.Sentinel("email already in use") // AddEmail adds a new email to the database, and generates a confirmation token for it. func (db *DB) AddEmail(ctx context.Context, tx pgx.Tx, userID common.UserID, email string) (e UserEmail, err error) { sql, args, err := sq.Insert("user_emails").SetMap(map[string]any{ - "id": common.GenerateID(), - "user_id": userID, - "email_address": email, - "confirmed": false, - "confirmation_token": common.RandBase64(48), + "id": common.GenerateID(), + "user_id": userID, + "email_address": email, }).Suffix("RETURNING *").ToSql() if err != nil { return e, errors.Wrap(err, "building query") @@ -91,3 +86,21 @@ func (db *DB) AddEmail(ctx context.Context, tx pgx.Tx, userID common.UserID, ema } return e, nil } + +func (db *DB) SetPassword(ctx context.Context, tx pgx.Tx, userID common.UserID, password string) (err error) { + salt := make([]byte, 16) + _, err = rand.Read(salt) + if err != nil { + return errors.Wrap(err, "creating salt") + } + + hash := hashPassword([]byte(password), salt) + + sql, args, err := sq.Update("users").Set("password", hash).Set("salt", salt).Where("snowflake_id = ?", userID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + return errors.Wrap(err, "executing query") +} diff --git a/backend/db/user.go b/backend/db/user.go index 5eacffa..2137d55 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -3,6 +3,7 @@ package db import ( "context" "crypto/sha256" + "crypto/subtle" "encoding/hex" "fmt" "regexp" @@ -18,6 +19,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/rs/xid" + "golang.org/x/crypto/argon2" ) type User struct { @@ -56,7 +58,8 @@ type User struct { LastSIDReroll time.Time `db:"last_sid_reroll"` Timezone *string Settings UserSettings - Password *string + Password []byte + Salt []byte DeletedAt *time.Time SelfDelete *bool @@ -133,6 +136,21 @@ func (u User) UTCOffset() (offset int, ok bool) { return offset, true } +func (u User) VerifyPassword(input string) bool { + if u.Password == nil || u.Salt == nil { + return false + } + + inputHash := hashPassword([]byte(input), u.Salt) + verifiedHash := hashPassword(u.Password, u.Salt) + + return subtle.ConstantTimeCompare(inputHash, verifiedHash) == 1 +} + +func hashPassword(password, salt []byte) []byte { + return argon2.IDKey(password, salt, 1, 65536, 4, 16) +} + type Badge int32 const ( diff --git a/backend/routes/v2/auth/email_signup.go b/backend/routes/v2/auth/email_signup.go index ab08d2d..63cd471 100644 --- a/backend/routes/v2/auth/email_signup.go +++ b/backend/routes/v2/auth/email_signup.go @@ -5,10 +5,12 @@ import ( "strings" "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" ) type postEmailSignupRequest struct { @@ -45,7 +47,7 @@ func (s *Server) postEmailSignup(w http.ResponseWriter, r *http.Request) (err er } go s.SendEmail(req.Email, "Confirm your email address", "signup", map[string]any{ - "ticket": ticket, + "Ticket": ticket, }) return nil @@ -54,3 +56,133 @@ func (s *Server) postEmailSignup(w http.ResponseWriter, r *http.Request) (err er func emailSignupTicketKey(ticket string) string { return "email-signup:" + ticket } + +type postEmailSignupConfirmRequest struct { + Ticket string `json:"ticket"` + Username string `json:"username"` + Password string `json:"password"` +} + +func (s *Server) postEmailSignupConfirm(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + var req postEmailSignupConfirmRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + var email string + err = s.DB.Redis.Do(ctx, radix.Cmd(&email, "GET", emailSignupTicketKey(email))) + if err != nil { + return errors.Wrap(err, "getting email signup key") + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "starting transaction") + } + defer func() { _ = tx.Rollback(ctx) }() + + u, err := s.DB.CreateUser(ctx, tx, req.Username) + if err != nil { + switch err { + case db.ErrUsernameTaken: + return server.APIError{Code: server.ErrUsernameTaken} + case db.ErrUsernameTooShort: + return server.APIError{Code: server.ErrBadRequest, Details: "Username is too short"} + case db.ErrUsernameTooLong: + return server.APIError{Code: server.ErrBadRequest, Details: "Username is too long"} + case db.ErrInvalidUsername: + return server.APIError{Code: server.ErrBadRequest, Details: "Username contains invalid characters"} + case db.ErrBannedUsername: + return server.APIError{Code: server.ErrBadRequest, Details: "Username is not allowed"} + default: + return errors.Wrap(err, "creating user") + } + } + + _, err = s.DB.AddEmail(ctx, tx, u.SnowflakeID, email) + if err != nil { + if err == db.ErrEmailInUse { + // This should only happen if the email was *not* taken when the ticket was sent, but was taken in the meantime. + // i.e. unless another person has access to the mailbox, the user will know what happened + return server.APIError{Code: server.ErrBadRequest, Details: "Email is already in use"} + } + + return errors.Wrap(err, "adding email to user") + } + + err = s.DB.SetPassword(ctx, tx, u.SnowflakeID, req.Password) + if err != nil { + return errors.Wrap(err, "setting password for user") + } + + // create token + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) + if err != nil { + return errors.Wrap(err, "creating token") + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + render.JSON(w, r, signupResponse{ + User: *dbUserToUserResponse(u, nil), + Token: token, + }) + + return nil +} + +type userResponse struct { + ID common.UserID `json:"id"` + Username string `json:"name"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + Avatar *string `json:"avatar"` + Links []string `json:"links"` + Names []db.FieldEntry `json:"names"` + Pronouns []db.PronounEntry `json:"pronouns"` + Fields []db.Field `json:"fields"` + + Discord *string `json:"discord"` + DiscordUsername *string `json:"discord_username"` + + Tumblr *string `json:"tumblr"` + TumblrUsername *string `json:"tumblr_username"` + + Google *string `json:"google"` + GoogleUsername *string `json:"google_username"` + + Fediverse *string `json:"fediverse"` + FediverseUsername *string `json:"fediverse_username"` + FediverseInstance *string `json:"fediverse_instance"` +} + +func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse { + return &userResponse{ + ID: u.SnowflakeID, + Username: u.Username, + DisplayName: u.DisplayName, + Bio: u.Bio, + Avatar: u.Avatar, + Links: db.NotNull(u.Links), + Names: db.NotNull(u.Names), + Pronouns: db.NotNull(u.Pronouns), + Fields: db.NotNull(fields), + } +} + +type signupResponse struct { + User userResponse `json:"user"` + Token string `json:"token"` +} diff --git a/backend/routes/v2/auth/get_emails.go b/backend/routes/v2/auth/get_emails.go index 08671c7..b17ecb9 100644 --- a/backend/routes/v2/auth/get_emails.go +++ b/backend/routes/v2/auth/get_emails.go @@ -12,16 +12,14 @@ import ( ) type EmailResponse struct { - ID common.EmailID `json:"id"` - Email string `json:"email"` - Confirmed bool `json:"confirmed"` + ID common.EmailID `json:"id"` + Email string `json:"email"` } func dbEmailToResponse(e db.UserEmail) EmailResponse { return EmailResponse{ - ID: e.ID, - Email: e.EmailAddress, - Confirmed: e.Confirmed, + ID: e.ID, + Email: e.EmailAddress, } } diff --git a/backend/routes/v2/auth/routes.go b/backend/routes/v2/auth/routes.go index ca46390..5e097dc 100644 --- a/backend/routes/v2/auth/routes.go +++ b/backend/routes/v2/auth/routes.go @@ -14,12 +14,14 @@ type Server struct { emailPool *email.Pool emailAddress string + baseURL string } func Mount(srv *server.Server, r chi.Router) { s := &Server{ Server: srv, emailAddress: os.Getenv("EMAIL_ADDRESS"), // The address used for sending email + baseURL: os.Getenv("BASE_URL"), // The frontend base URL } // Only mount email routes if email is set up @@ -38,10 +40,10 @@ func Mount(srv *server.Server, r chi.Router) { r.With(server.MustAuth).Post("/", nil) // Add/update email to existing account, { email } r.With(server.MustAuth).Delete("/{id}", nil) // Remove existing email from account, - r.Post("/login", nil) // Log in to account, { username, password } - r.Post("/signup", server.WrapHandler(s.postEmailSignup)) // Create account, { email } - r.Post("/signup/confirm", nil) // Create account, { ticket, username, password } - r.Post("/confirm", nil) // Confirm email address, { ticket } + r.Post("/login", nil) // Log in to account, { username, password } + r.Post("/signup", server.WrapHandler(s.postEmailSignup)) // Create account, { email } + r.Post("/signup/confirm", server.WrapHandler(s.postEmailSignupConfirm)) // Create account, { ticket, username, password } + r.Post("/confirm", nil) // Confirm email address, { ticket } r.Patch("/password", nil) // Update password r.Post("/password/forgot", nil) // Forgot/reset password, { email } diff --git a/backend/routes/v2/auth/send_email.go b/backend/routes/v2/auth/send_email.go index 84cc1d0..763f1d4 100644 --- a/backend/routes/v2/auth/send_email.go +++ b/backend/routes/v2/auth/send_email.go @@ -1,9 +1,14 @@ package auth import ( - "codeberg.org/pronounscc/pronouns.cc/backend/log" - "github.com/jordan-wright/email" + "bytes" + "embed" + "html/template" "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "emperror.dev/errors" + "github.com/jordan-wright/email" ) func (s *Server) SendEmail(to, title, template string, data map[string]any) { @@ -11,8 +16,39 @@ func (s *Server) SendEmail(to, title, template string, data map[string]any) { e.From = s.emailAddress e.To = []string{to} - err := s.emailPool.Send(e, 10*time.Second) + text, html, err := s.Template(template, data) + if err != nil { + log.Errorf("executing templates: %v", err) + return + } + e.Text = text + e.HTML = html + + err = s.emailPool.Send(e, 10*time.Second) if err != nil { log.Errorf("sending email: %v", err) } } + +//go:embed templates/* +var fs embed.FS +var tmpl = template.Must(template.ParseFS(fs, "templates/*")) + +func (s *Server) Template(template string, data map[string]any) (text, html []byte, err error) { + data["BaseURL"] = s.baseURL + + textWriter := new(bytes.Buffer) + htmlWriter := new(bytes.Buffer) + + err = tmpl.ExecuteTemplate(textWriter, "templates/"+template+".txt", data) + if err != nil { + return nil, nil, errors.Wrap(err, "executing text template") + } + + err = tmpl.ExecuteTemplate(htmlWriter, "templates/"+template+".html", data) + if err != nil { + return nil, nil, errors.Wrap(err, "executing HTML template") + } + + return textWriter.Bytes(), htmlWriter.Bytes(), nil +} diff --git a/backend/routes/v2/auth/templates/signup.html b/backend/routes/v2/auth/templates/signup.html new file mode 100644 index 0000000..f365f03 --- /dev/null +++ b/backend/routes/v2/auth/templates/signup.html @@ -0,0 +1,16 @@ + + + + + + + + +

Please continue creating a new pronouns.cc account by using the following link:

+

Confirm your email address

+ + diff --git a/backend/routes/v2/auth/templates/signup.txt b/backend/routes/v2/auth/templates/signup.txt index e69de29..7c7b1c2 100644 --- a/backend/routes/v2/auth/templates/signup.txt +++ b/backend/routes/v2/auth/templates/signup.txt @@ -0,0 +1,4 @@ +Please continue creating a new pronouns.cc account by using the following link: +{{.BaseURL}}/auth/email/signup/{{.Ticket}} + +If you didn't mean to create a new account, feel free to ignore this email. diff --git a/go.mod b/go.mod index 126db75..0e2a7c0 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/toshi0607/chi-prometheus v0.1.4 github.com/urfave/cli/v2 v2.25.7 go.uber.org/zap v1.26.0 + golang.org/x/crypto v0.19.0 golang.org/x/oauth2 v0.13.0 google.golang.org/api v0.148.0 gopkg.in/yaml.v3 v3.0.1 @@ -71,12 +72,11 @@ require ( github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.14.0 // indirect golang.org/x/image v0.13.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/sync v0.4.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20231012201019-e917dd12ba7a // indirect google.golang.org/grpc v1.59.0 // indirect diff --git a/go.sum b/go.sum index d14d1d3..d09764e 100644 --- a/go.sum +++ b/go.sum @@ -207,8 +207,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4= golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg= @@ -248,19 +248,19 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/scripts/migrate/023_email.sql b/scripts/migrate/023_email.sql index 1c0dd33..e1a9118 100644 --- a/scripts/migrate/023_email.sql +++ b/scripts/migrate/023_email.sql @@ -8,9 +8,7 @@ alter table users add column salt bytea null; create table user_emails ( id bigint primary key, user_id bigint not null references users (snowflake_id) on delete cascade, - email_address text not null unique, - confirmed boolean not null default false, - confirmation_token text null unique + email_address text not null unique ); -- +migrate Down