diff --git a/backend/common/common.go b/backend/common/common.go index 92c6169..0da74fc 100644 --- a/backend/common/common.go +++ b/backend/common/common.go @@ -1,7 +1,11 @@ // Package common contains functions and types common to all (or most) packages. package common -import "unicode/utf8" +import ( + "crypto/rand" + "encoding/base64" + "unicode/utf8" +) func StringLength(s *string) int { if s == nil { @@ -9,3 +13,14 @@ func StringLength(s *string) int { } return utf8.RuneCountInString(*s) } + +// RandBase64 returns a string of random bytes encoded in raw base 64. +func RandBase64(size int) string { + b := make([]byte, size) + _, err := rand.Read(b) + if err != nil { + panic(err) + } + + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/backend/common/snowflake_types.go b/backend/common/snowflake_types.go index 3e9848f..23736dc 100644 --- a/backend/common/snowflake_types.go +++ b/backend/common/snowflake_types.go @@ -37,3 +37,15 @@ func (id FlagID) Increment() uint16 { return Snowflake(id).Increment() } func (id FlagID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() } func (id *FlagID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) } + +type EmailID Snowflake + +func (id EmailID) String() string { return Snowflake(id).String() } +func (id EmailID) Time() time.Time { return Snowflake(id).Time() } +func (id EmailID) IsValid() bool { return Snowflake(id).IsValid() } +func (id EmailID) Worker() uint8 { return Snowflake(id).Worker() } +func (id EmailID) PID() uint8 { return Snowflake(id).PID() } +func (id EmailID) Increment() uint16 { return Snowflake(id).Increment() } + +func (id EmailID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() } +func (id *EmailID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) } diff --git a/backend/db/email.go b/backend/db/email.go new file mode 100644 index 0000000..593d9ad --- /dev/null +++ b/backend/db/email.go @@ -0,0 +1,93 @@ +package db + +import ( + "context" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "emperror.dev/errors" + "github.com/georgysavva/scany/v2/pgxscan" +) + +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) { + sql, args, err := sq.Select("*").From("user_emails").Where("user_id = ?", userID).OrderBy("id").ToSql() + if err != nil { + return nil, errors.Wrap(err, "building query") + } + + err = pgxscan.Select(ctx, db, &es, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + + return NotNull(es), nil +} + +// 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") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return u, ErrUserNotFound + } + return u, errors.Wrap(err, "executing query") + } + return u, nil +} + +// EmailExists returns whether an email address already exists. It does not need to be comfirmed. +func (db *DB) EmailExists(ctx context.Context, email string) (exists bool, err error) { + err = db.QueryRow(ctx, "select exists(SELECT * FROM user_emails WHERE email = $1)", email).Scan(&exists) + return exists, err +} + +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), + }).Suffix("RETURNING *").ToSql() + if err != nil { + return e, errors.Wrap(err, "building query") + } + + err = pgxscan.Get(ctx, tx, &e, sql, args...) + if err != nil { + pge := &pgconn.PgError{} + if errors.As(err, &pge) { + // unique constraint violation + if pge.Code == uniqueViolation { + return e, ErrEmailInUse + } + } + + return e, errors.Wrap(err, "executing query") + } + return e, nil +} diff --git a/backend/db/user.go b/backend/db/user.go index 57e0a71..5eacffa 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -56,6 +56,7 @@ type User struct { LastSIDReroll time.Time `db:"last_sid_reroll"` Timezone *string Settings UserSettings + Password *string DeletedAt *time.Time SelfDelete *bool diff --git a/backend/routes.go b/backend/routes.go index 406e05f..701912c 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -6,11 +6,10 @@ import ( "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/meta" "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/mod" "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/user" + auth2 "codeberg.org/pronounscc/pronouns.cc/backend/routes/v2/auth" user2 "codeberg.org/pronounscc/pronouns.cc/backend/routes/v2/user" "codeberg.org/pronounscc/pronouns.cc/backend/server" "github.com/go-chi/chi/v5" - - _ "embed" ) // mountRoutes mounts all API routes on the server's router. @@ -26,5 +25,6 @@ func mountRoutes(s *server.Server) { s.Router.Route("/v2", func(r chi.Router) { user2.Mount(s, r) + auth2.Mount(s, r) }) } diff --git a/backend/routes/v1/auth/discord.go b/backend/routes/v1/auth/discord.go index 2084b52..c725a2b 100644 --- a/backend/routes/v1/auth/discord.go +++ b/backend/routes/v1/auth/discord.go @@ -5,6 +5,7 @@ import ( "os" "time" + "codeberg.org/pronounscc/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/server" @@ -141,7 +142,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { } // no user found, so save a ticket + save their Discord info in Redis - ticket := RandBase64(32) + ticket := common.RandBase64(32) err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600") if err != nil { log.Errorf("setting Discord user for ticket %q: %v", ticket, err) diff --git a/backend/routes/v1/auth/fedi_mastodon.go b/backend/routes/v1/auth/fedi_mastodon.go index bb07958..e86827f 100644 --- a/backend/routes/v1/auth/fedi_mastodon.go +++ b/backend/routes/v1/auth/fedi_mastodon.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "codeberg.org/pronounscc/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/server" @@ -162,7 +163,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error } // no user found, so save a ticket + save their Mastodon info in Redis - ticket := RandBase64(32) + ticket := common.RandBase64(32) err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600") if err != nil { log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err) diff --git a/backend/routes/v1/auth/fedi_misskey.go b/backend/routes/v1/auth/fedi_misskey.go index ebf6e22..36637e6 100644 --- a/backend/routes/v1/auth/fedi_misskey.go +++ b/backend/routes/v1/auth/fedi_misskey.go @@ -7,6 +7,7 @@ import ( "io" "net/http" + "codeberg.org/pronounscc/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/server" @@ -141,7 +142,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error { } // no user found, so save a ticket + save their Misskey info in Redis - ticket := RandBase64(32) + ticket := common.RandBase64(32) err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600") if err != nil { log.Errorf("setting misskey user for ticket %q: %v", ticket, err) diff --git a/backend/routes/v1/auth/google.go b/backend/routes/v1/auth/google.go index 026a6d1..f0a680b 100644 --- a/backend/routes/v1/auth/google.go +++ b/backend/routes/v1/auth/google.go @@ -5,6 +5,7 @@ import ( "os" "time" + "codeberg.org/pronounscc/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/server" @@ -160,7 +161,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error { } // no user found, so save a ticket + save their Google info in Redis - ticket := RandBase64(32) + ticket := common.RandBase64(32) err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600") if err != nil { log.Errorf("setting Google user for ticket %q: %v", ticket, err) diff --git a/backend/routes/v1/auth/oauth.go b/backend/routes/v1/auth/oauth.go index 4232cec..ef6ec4f 100644 --- a/backend/routes/v1/auth/oauth.go +++ b/backend/routes/v1/auth/oauth.go @@ -2,9 +2,8 @@ package auth import ( "context" - "crypto/rand" - "encoding/base64" + "codeberg.org/pronounscc/pronouns.cc/backend/common" "github.com/mediocregopher/radix/v4" ) @@ -14,7 +13,7 @@ const numStates = "1000" // setCSRFState generates a random string to use as state, then stores that in Redis. func (s *Server) setCSRFState(ctx context.Context) (string, error) { - state := RandBase64(32) + state := common.RandBase64(32) err := s.DB.MultiCmd(ctx, radix.Cmd(nil, "LPUSH", "csrf", state), @@ -32,14 +31,3 @@ func (s *Server) validateCSRFState(ctx context.Context, state string) (matched b } return num > 0, nil } - -// RandBase64 returns a string of random bytes encoded in raw base 64. -func RandBase64(size int) string { - b := make([]byte, size) - _, err := rand.Read(b) - if err != nil { - panic(err) - } - - return base64.RawURLEncoding.EncodeToString(b) -} diff --git a/backend/routes/v1/auth/tumblr.go b/backend/routes/v1/auth/tumblr.go index 52dcdec..6b81013 100644 --- a/backend/routes/v1/auth/tumblr.go +++ b/backend/routes/v1/auth/tumblr.go @@ -7,6 +7,7 @@ import ( "os" "time" + "codeberg.org/pronounscc/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/server" @@ -193,7 +194,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error { } // no user found, so save a ticket + save their Tumblr info in Redis - ticket := RandBase64(32) + ticket := common.RandBase64(32) err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600") if err != nil { log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err) diff --git a/backend/routes/v1/meta/meta.go b/backend/routes/v1/meta/meta.go index 15cc2fd..780f74e 100644 --- a/backend/routes/v1/meta/meta.go +++ b/backend/routes/v1/meta/meta.go @@ -13,10 +13,17 @@ import ( type Server struct { *server.Server + + requireInvite bool + emailSignup bool } func Mount(srv *server.Server, r chi.Router) { - s := &Server{Server: srv} + s := &Server{ + Server: srv, + requireInvite: os.Getenv("REQUIRE_INVITE") == "true", + emailSignup: os.Getenv("EMAIL_SMTP_ADDRESS") != "", + } r.Get("/meta", server.WrapHandler(s.meta)) } @@ -27,6 +34,7 @@ type MetaResponse struct { Users MetaUsers `json:"users"` Members int64 `json:"members"` RequireInvite bool `json:"require_invite"` + EmailSignup bool `json:"email_signup"` Notice *MetaNotice `json:"notice"` } @@ -69,7 +77,8 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error { ActiveDay: activeDay, }, Members: numMembers, - RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", + RequireInvite: s.requireInvite, + EmailSignup: s.emailSignup, Notice: notice, }) return nil diff --git a/backend/routes/v2/auth/email_signup.go b/backend/routes/v2/auth/email_signup.go new file mode 100644 index 0000000..ab08d2d --- /dev/null +++ b/backend/routes/v2/auth/email_signup.go @@ -0,0 +1,56 @@ +package auth + +import ( + "net/http" + "strings" + + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" +) + +type postEmailSignupRequest struct { + Email string `json:"email"` +} + +func (s *Server) postEmailSignup(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + var req postEmailSignupRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if !strings.Contains(req.Email, "@") { + return server.APIError{Code: server.ErrBadRequest, Details: "Email seems to be invalid"} + } + + ticket := common.RandBase64(48) + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "SET", emailSignupTicketKey(ticket), req.Email, "EX", "3600")) + if err != nil { + return errors.Wrap(err, "setting email signup key") + } + + // if the email address already exists, pretend we sent an email and return + // to prevent people from discovering valid email addresses + exists, err := s.DB.EmailExists(ctx, req.Email) + if err != nil { + return errors.Wrap(err, "checking if email exists") + } + if exists { + render.NoContent(w, r) + return nil + } + + go s.SendEmail(req.Email, "Confirm your email address", "signup", map[string]any{ + "ticket": ticket, + }) + + return nil +} + +func emailSignupTicketKey(ticket string) string { + return "email-signup:" + ticket +} diff --git a/backend/routes/v2/auth/get_emails.go b/backend/routes/v2/auth/get_emails.go new file mode 100644 index 0000000..08671c7 --- /dev/null +++ b/backend/routes/v2/auth/get_emails.go @@ -0,0 +1,55 @@ +package auth + +import ( + "net/http" + + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" +) + +type EmailResponse struct { + ID common.EmailID `json:"id"` + Email string `json:"email"` + Confirmed bool `json:"confirmed"` +} + +func dbEmailToResponse(e db.UserEmail) EmailResponse { + return EmailResponse{ + ID: e.ID, + Email: e.EmailAddress, + Confirmed: e.Confirmed, + } +} + +func (s *Server) getEmails(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + // this should never fail + log.Errorf("getting user: %v", err) + return errors.Wrap(err, "getting user") + } + + emails, err := s.DB.UserEmails(ctx, u.SnowflakeID) + if err != nil { + log.Errorf("getting user emails: %v", err) + return errors.Wrap(err, "getting user emails") + } + + out := make([]EmailResponse, len(emails)) + for i := range emails { + out[i] = dbEmailToResponse(emails[i]) + } + + render.JSON(w, r, out) + return nil +} diff --git a/backend/routes/v2/auth/routes.go b/backend/routes/v2/auth/routes.go new file mode 100644 index 0000000..ca46390 --- /dev/null +++ b/backend/routes/v2/auth/routes.go @@ -0,0 +1,51 @@ +package auth + +import ( + "net/smtp" + "os" + + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "github.com/go-chi/chi/v5" + "github.com/jordan-wright/email" +) + +type Server struct { + *server.Server + + emailPool *email.Pool + emailAddress string +} + +func Mount(srv *server.Server, r chi.Router) { + s := &Server{ + Server: srv, + emailAddress: os.Getenv("EMAIL_ADDRESS"), // The address used for sending email + } + + // Only mount email routes if email is set up + if os.Getenv("EMAIL_SMTP_ADDRESS") != "" { + // This function will never return an error + s.emailPool, _ = email.NewPool( + os.Getenv("EMAIL_SMTP_ADDRESS"), // This should be the SMTP server host, like "example.com:587" + 4, smtp.PlainAuth("", + os.Getenv("EMAIL_USERNAME"), // This should be the account name, like "noreply@pronouns.cc" + os.Getenv("EMAIL_PASSWORD"), // This should be the account pass, like "6GzW3dY6" + os.Getenv("EMAIL_HOST"), // This should be the email host (seems to be SMTP server without the port?) + )) + + r.Route("/auth/email", func(r chi.Router) { + r.With(server.MustAuth).Get("/", server.WrapHandler(s.getEmails)) // List existing email addresses for account + 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.Patch("/password", nil) // Update password + r.Post("/password/forgot", nil) // Forgot/reset password, { email } + r.Post("/password/confirm", nil) // Confirm resetting password, { ticket, new_password } + }) + } +} diff --git a/backend/routes/v2/auth/send_email.go b/backend/routes/v2/auth/send_email.go new file mode 100644 index 0000000..84cc1d0 --- /dev/null +++ b/backend/routes/v2/auth/send_email.go @@ -0,0 +1,18 @@ +package auth + +import ( + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "github.com/jordan-wright/email" + "time" +) + +func (s *Server) SendEmail(to, title, template string, data map[string]any) { + e := email.NewEmail() + e.From = s.emailAddress + e.To = []string{to} + + err := s.emailPool.Send(e, 10*time.Second) + if err != nil { + log.Errorf("sending email: %v", err) + } +} diff --git a/backend/routes/v2/auth/templates/signup.txt b/backend/routes/v2/auth/templates/signup.txt new file mode 100644 index 0000000..e69de29 diff --git a/go.mod b/go.mod index 3967e70..126db75 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/google/uuid v1.4.0 github.com/jackc/pgx/v5 v5.4.3 github.com/joho/godotenv v1.5.1 + github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/mediocregopher/radix/v4 v4.1.4 github.com/minio/minio-go/v7 v7.0.63 github.com/prometheus/client_golang v1.17.0 diff --git a/go.sum b/go.sum index fb00aa8..d14d1d3 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,8 @@ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw= diff --git a/scripts/migrate/023_email.sql b/scripts/migrate/023_email.sql new file mode 100644 index 0000000..1c0dd33 --- /dev/null +++ b/scripts/migrate/023_email.sql @@ -0,0 +1,21 @@ +-- 2023-10-28: Add email/password login + +-- +migrate Up + +alter table users add column password bytea null; +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 +); + +-- +migrate Down + +alter table users drop column password; +alter table users drop column salt; + +drop table user_emails;