feat: add email/password login

This commit is contained in:
sam 2024-02-16 14:50:41 +01:00
parent 12ed7fb5bb
commit 1cfe28cd59
No known key found for this signature in database
GPG key ID: B4EF20DDE721CAA1
17 changed files with 188 additions and 59 deletions

View file

@ -55,7 +55,7 @@ func (db *DB) UserByEmail(ctx context.Context, email string) (u User, err error)
// EmailExists returns whether an email address already exists. It does not need to be comfirmed. // 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) { 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) err = db.QueryRow(ctx, "select exists(SELECT * FROM user_emails WHERE email_address = $1)", email).Scan(&exists)
return exists, err return exists, err
} }

View file

@ -64,7 +64,7 @@ func (db *DB) Tokens(ctx context.Context, userID xid.ID) (ts []Token, err error)
const TokenExpiryTime = 3 * 30 * 24 * time.Hour const TokenExpiryTime = 3 * 30 * 24 * time.Hour
// SaveToken saves a token to the database. // SaveToken saves a token to the database.
func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID, apiOnly, readOnly bool) (t Token, err error) { func (db *DB) SaveToken(ctx context.Context, q pgxscan.Querier, userID xid.ID, tokenID xid.ID, apiOnly, readOnly bool) (t Token, err error) {
sql, args, err := sq.Insert("tokens"). sql, args, err := sq.Insert("tokens").
SetMap(map[string]any{ SetMap(map[string]any{
"user_id": userID, "user_id": userID,
@ -79,7 +79,7 @@ func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID, apiO
return t, errors.Wrap(err, "building sql") return t, errors.Wrap(err, "building sql")
} }
err = pgxscan.Get(ctx, db, &t, sql, args...) err = pgxscan.Get(ctx, q, &t, sql, args...)
if err != nil { if err != nil {
return t, errors.Wrap(err, "inserting token") return t, errors.Wrap(err, "inserting token")
} }

View file

@ -142,9 +142,7 @@ func (u User) VerifyPassword(input string) bool {
} }
inputHash := hashPassword([]byte(input), u.Salt) inputHash := hashPassword([]byte(input), u.Salt)
verifiedHash := hashPassword(u.Password, u.Salt) return subtle.ConstantTimeCompare(inputHash, u.Password) == 1
return subtle.ConstantTimeCompare(inputHash, verifiedHash) == 1
} }
func hashPassword(password, salt []byte) []byte { func hashPassword(password, salt []byte) []byte {

View file

@ -120,7 +120,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
} }
// save token to database // save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) _, err = s.DB.SaveToken(ctx, s.DB, u.ID, tokenID, false, false)
if err != nil { if err != nil {
return errors.Wrap(err, "saving token to database") return errors.Wrap(err, "saving token to database")
} }
@ -361,12 +361,6 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "deleting signup ticket") return errors.Wrap(err, "deleting signup ticket")
} }
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// create token // create token
// TODO: implement user + token permissions // TODO: implement user + token permissions
tokenID := xid.New() tokenID := xid.New()
@ -376,11 +370,17 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
} }
// save token to database // save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) _, err = s.DB.SaveToken(ctx, tx, u.ID, tokenID, false, false)
if err != nil { if err != nil {
return errors.Wrap(err, "saving token to database") return errors.Wrap(err, "saving token to database")
} }
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// return user // return user
render.JSON(w, r, signupResponse{ render.JSON(w, r, signupResponse{
User: *dbUserToUserResponse(u, nil), User: *dbUserToUserResponse(u, nil),

View file

@ -141,7 +141,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
} }
// save token to database // save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) _, err = s.DB.SaveToken(ctx, s.DB, u.ID, tokenID, false, false)
if err != nil { if err != nil {
return errors.Wrap(err, "saving token to database") return errors.Wrap(err, "saving token to database")
} }
@ -389,12 +389,6 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "deleting signup ticket") return errors.Wrap(err, "deleting signup ticket")
} }
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// create token // create token
// TODO: implement user + token permissions // TODO: implement user + token permissions
tokenID := xid.New() tokenID := xid.New()
@ -404,11 +398,17 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
} }
// save token to database // save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) _, err = s.DB.SaveToken(ctx, tx, u.ID, tokenID, false, false)
if err != nil { if err != nil {
return errors.Wrap(err, "saving token to database") return errors.Wrap(err, "saving token to database")
} }
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// return user // return user
render.JSON(w, r, signupResponse{ render.JSON(w, r, signupResponse{
User: *dbUserToUserResponse(u, nil), User: *dbUserToUserResponse(u, nil),

View file

@ -120,7 +120,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
} }
// save token to database // save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) _, err = s.DB.SaveToken(ctx, s.DB, u.ID, tokenID, false, false)
if err != nil { if err != nil {
return errors.Wrap(err, "saving token to database") return errors.Wrap(err, "saving token to database")
} }
@ -317,12 +317,6 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "deleting signup ticket") return errors.Wrap(err, "deleting signup ticket")
} }
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// create token // create token
// TODO: implement user + token permissions // TODO: implement user + token permissions
tokenID := xid.New() tokenID := xid.New()
@ -332,11 +326,17 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
} }
// save token to database // save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) _, err = s.DB.SaveToken(ctx, tx, u.ID, tokenID, false, false)
if err != nil { if err != nil {
return errors.Wrap(err, "saving token to database") return errors.Wrap(err, "saving token to database")
} }
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// return user // return user
render.JSON(w, r, signupResponse{ render.JSON(w, r, signupResponse{
User: *dbUserToUserResponse(u, nil), User: *dbUserToUserResponse(u, nil),

View file

@ -139,7 +139,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
} }
// save token to database // save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) _, err = s.DB.SaveToken(ctx, s.DB, u.ID, tokenID, false, false)
if err != nil { if err != nil {
return errors.Wrap(err, "saving token to database") return errors.Wrap(err, "saving token to database")
} }
@ -364,12 +364,6 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "deleting signup ticket") return errors.Wrap(err, "deleting signup ticket")
} }
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// create token // create token
// TODO: implement user + token permissions // TODO: implement user + token permissions
tokenID := xid.New() tokenID := xid.New()
@ -379,11 +373,17 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
} }
// save token to database // save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) _, err = s.DB.SaveToken(ctx, tx, u.ID, tokenID, false, false)
if err != nil { if err != nil {
return errors.Wrap(err, "saving token to database") return errors.Wrap(err, "saving token to database")
} }
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// return user // return user
render.JSON(w, r, signupResponse{ render.JSON(w, r, signupResponse{
User: *dbUserToUserResponse(u, nil), User: *dbUserToUserResponse(u, nil),

View file

@ -115,7 +115,7 @@ func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "creating token") return errors.Wrap(err, "creating token")
} }
t, err := s.DB.SaveToken(ctx, claims.UserID, tokenID, true, readOnly) t, err := s.DB.SaveToken(ctx, s.DB, claims.UserID, tokenID, true, readOnly)
if err != nil { if err != nil {
return errors.Wrap(err, "saving token") return errors.Wrap(err, "saving token")
} }

View file

@ -172,7 +172,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
} }
// save token to database // save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) _, err = s.DB.SaveToken(ctx, s.DB, u.ID, tokenID, false, false)
if err != nil { if err != nil {
return errors.Wrap(err, "saving token to database") return errors.Wrap(err, "saving token to database")
} }
@ -397,12 +397,6 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "deleting signup ticket") return errors.Wrap(err, "deleting signup ticket")
} }
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// create token // create token
// TODO: implement user + token permissions // TODO: implement user + token permissions
tokenID := xid.New() tokenID := xid.New()
@ -412,11 +406,17 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
} }
// save token to database // save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) _, err = s.DB.SaveToken(ctx, s.DB, u.ID, tokenID, false, false)
if err != nil { if err != nil {
return errors.Wrap(err, "saving token to database") return errors.Wrap(err, "saving token to database")
} }
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// return user // return user
render.JSON(w, r, signupResponse{ render.JSON(w, r, signupResponse{
User: *dbUserToUserResponse(u, nil), User: *dbUserToUserResponse(u, nil),

View file

@ -1,6 +1,7 @@
package auth package auth
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
@ -72,10 +73,14 @@ func (s *Server) postEmailSignupConfirm(w http.ResponseWriter, r *http.Request)
} }
var email string var email string
err = s.DB.Redis.Do(ctx, radix.Cmd(&email, "GET", emailSignupTicketKey(email))) fmt.Println(emailSignupTicketKey(req.Ticket))
err = s.DB.Redis.Do(ctx, radix.Cmd(&email, "GET", emailSignupTicketKey(req.Ticket)))
if err != nil { if err != nil {
return errors.Wrap(err, "getting email signup key") return errors.Wrap(err, "getting email signup key")
} }
if email == "" {
return server.APIError{Code: server.ErrBadRequest, Details: "Unknown ticket"}
}
tx, err := s.DB.Begin(ctx) tx, err := s.DB.Begin(ctx)
if err != nil { if err != nil {
@ -125,7 +130,7 @@ func (s *Server) postEmailSignupConfirm(w http.ResponseWriter, r *http.Request)
} }
// save token to database // save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) _, err = s.DB.SaveToken(ctx, tx, u.ID, tokenID, false, false)
if err != nil { if err != nil {
return errors.Wrap(err, "saving token to database") return errors.Wrap(err, "saving token to database")
} }

View file

@ -0,0 +1,62 @@
package auth
import (
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/rs/xid"
)
type postLoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type postLoginResponse struct {
User *userResponse `json:"user,omitempty"`
Token string `json:"token,omitempty"`
}
func (s *Server) postLogin(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
var req postLoginRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
u, err := s.DB.UserByEmail(ctx, req.Email)
if err != nil {
return server.APIError{Code: server.ErrForbidden, Details: "Invalid email or password"}
}
if !u.VerifyPassword(req.Password) {
return server.APIError{Code: server.ErrForbidden, Details: "Invalid email or password"}
}
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return errors.Wrap(err, "creating token")
}
// save token to database
_, err = s.DB.SaveToken(ctx, s.DB, u.ID, tokenID, false, false)
if err != nil {
return errors.Wrap(err, "saving token to database")
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "querying fields")
}
render.JSON(w, r, postLoginResponse{
Token: token,
User: dbUserToUserResponse(u, fields),
})
return nil
}

View file

@ -40,7 +40,7 @@ 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).Post("/", nil) // Add/update email to existing account, { email }
r.With(server.MustAuth).Delete("/{id}", nil) // Remove existing email from account, <no body> 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("/login", server.WrapHandler(s.postLogin)) // Log in to account, { username, password }
r.Post("/signup", server.WrapHandler(s.postEmailSignup)) // Create account, { email } 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("/signup/confirm", server.WrapHandler(s.postEmailSignupConfirm)) // Create account, { ticket, username, password }
r.Post("/confirm", nil) // Confirm email address, { ticket } r.Post("/confirm", nil) // Confirm email address, { ticket }

View file

@ -40,12 +40,12 @@ func (s *Server) Template(template string, data map[string]any) (text, html []by
textWriter := new(bytes.Buffer) textWriter := new(bytes.Buffer)
htmlWriter := new(bytes.Buffer) htmlWriter := new(bytes.Buffer)
err = tmpl.ExecuteTemplate(textWriter, "templates/"+template+".txt", data) err = tmpl.ExecuteTemplate(textWriter, template+".txt", data)
if err != nil { if err != nil {
return nil, nil, errors.Wrap(err, "executing text template") return nil, nil, errors.Wrap(err, "executing text template")
} }
err = tmpl.ExecuteTemplate(htmlWriter, "templates/"+template+".html", data) err = tmpl.ExecuteTemplate(htmlWriter, template+".html", data)
if err != nil { if err != nil {
return nil, nil, errors.Wrap(err, "executing HTML template") return nil, nil, errors.Wrap(err, "executing HTML template")
} }

View file

@ -11,6 +11,7 @@ export interface MetaResponse {
users: MetaUsers; users: MetaUsers;
members: number; members: number;
require_invite: boolean; require_invite: boolean;
email_signup: boolean;
notice: { id: number; notice: string } | null; notice: { id: number; notice: string } | null;
} }

View file

@ -1,6 +1,7 @@
import { apiFetch } from "$lib/api/fetch"; import { apiFetch } from "$lib/api/fetch";
import { PUBLIC_BASE_URL } from "$env/static/public"; import { PUBLIC_BASE_URL } from "$env/static/public";
import type { UrlsResponse } from "$lib/api/responses"; import type { UrlsResponse } from "$lib/api/responses";
import type { APIError, MeUser } from "$lib/api/entities";
export const load = async () => { export const load = async () => {
const resp = await apiFetch<UrlsResponse>("/auth/urls", { const resp = await apiFetch<UrlsResponse>("/auth/urls", {
@ -12,3 +13,29 @@ export const load = async () => {
return resp; return resp;
}; };
interface LoginResponse {
user?: MeUser;
token: string;
}
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
try {
const resp = await apiFetch<LoginResponse>("/auth/email/login", {
method: "POST",
version: 2,
body: {
email: data.get("email"),
password: data.get("password"),
},
});
return { data: resp };
} catch (e) {
return { error: e as APIError };
}
},
};

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from "$app/forms";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { APIError } from "$lib/api/entities"; import type { APIError } from "$lib/api/entities";
import { apiFetch } from "$lib/api/fetch"; import { apiFetch } from "$lib/api/fetch";
@ -8,6 +9,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { import {
Button, Button,
FormGroup,
Icon, Icon,
Input, Input,
ListGroup, ListGroup,
@ -16,9 +18,21 @@
ModalBody, ModalBody,
ModalFooter, ModalFooter,
} from "@sveltestrap/sveltestrap"; } from "@sveltestrap/sveltestrap";
import type { PageData } from "./$types"; import type { PageData, ActionData } from "./$types";
export let data: PageData; export let data: PageData;
export let form: ActionData;
$: loginCallback(form);
const loginCallback = (form: ActionData) => {
if (!form?.data?.user) return;
localStorage.setItem("pronouns-token", form.data.token);
localStorage.setItem("pronouns-user", JSON.stringify(form.data.user));
userStore.set(form.data.user);
addToast({ header: "Logged in", body: "Successfully logged in!" });
goto(`/@${form.data.user.name}`);
};
let error: APIError | null = null; let error: APIError | null = null;
let instance = ""; let instance = "";
@ -59,7 +73,31 @@
<div class="container"> <div class="container">
<h1>Log in or sign up</h1> <h1>Log in or sign up</h1>
<div class="row"> <div class="row">
<div class="col-md-4 mb-1"> <div class="col-md">
{#if data.email_signup}
{#if form?.error}
<ErrorAlert error={form?.error} />
{/if}
<form method="POST" use:enhance>
<FormGroup class="m-1" floating label="Email">
<Input name="email" type="email" />
</FormGroup>
<FormGroup class="m-1" floating label="Password">
<Input name="password" type="password" />
</FormGroup>
<p class="m-1">
<Button color="primary" type="submit">Log in</Button>
<Button href="/auth/signup">Sign up</Button>
</p>
</form>
{:else}
<p>
<b>Choose an authentication provider to get started.</b> You can add more providers later.
</p>
{/if}
</div>
<div class="col-md">
<ListGroup> <ListGroup>
<ListGroupItem tag="button" on:click={toggleModal}>Log in with the Fediverse</ListGroupItem> <ListGroupItem tag="button" on:click={toggleModal}>Log in with the Fediverse</ListGroupItem>
{#if data.discord} {#if data.discord}
@ -101,10 +139,5 @@
a token in your browser to identify your account. a token in your browser to identify your account.
</p> </p>
</div> </div>
<div class="col-md">
<p>
<b>Choose an authentication provider to get started.</b> You can add more providers later.
</p>
</div>
</div> </div>
</div> </div>

View file

@ -13,6 +13,9 @@ const config = {
version: { version: {
name: child_process.execSync("git describe --tags --long --always").toString().trim(), name: child_process.execSync("git describe --tags --long --always").toString().trim(),
}, },
csrf: {
checkOrigin: process.env.NODE_ENV !== "development",
},
}, },
}; };