start email functions

This commit is contained in:
sam 2023-12-18 23:39:16 +01:00
parent 34002e77d9
commit 283cc1681c
No known key found for this signature in database
GPG key ID: B4EF20DDE721CAA1
20 changed files with 351 additions and 24 deletions

View file

@ -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)
}

View file

@ -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) }

93
backend/db/email.go Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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)
})
}

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)
}

View file

@ -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)

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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, <no body>
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 }
})
}
}

View file

@ -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)
}
}

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

@ -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;