add frontend auth middleware, embed user data in frontend html

This commit is contained in:
sam 2023-09-04 17:32:45 +02:00
parent d8cb8c8fa8
commit 0fa769a248
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
12 changed files with 265 additions and 42 deletions

View file

@ -1,7 +1,16 @@
<script lang="ts"> <script lang="ts">
import svelteLogo from './assets/svelte.svg' import { onMount } from "svelte";
import viteLogo from './assets/vite.svg' import svelteLogo from "./assets/svelte.svg";
import Counter from './lib/Counter.svelte' import viteLogo from "./assets/vite.svg";
import Counter from "./lib/Counter.svelte";
import type { MeAccount } from "./lib/api/account";
let account: MeAccount | null = null;
onMount(() => {
const accountData = document.getElementById("accountData");
account = JSON.parse(accountData.innerHTML) as MeAccount;
});
</script> </script>
<main> <main>
@ -20,12 +29,20 @@
</div> </div>
<p> <p>
Check out <a href="https://github.com/sveltejs/kit#readme" target="_blank" rel="noreferrer">SvelteKit</a>, the official Svelte app framework powered by Vite! Check out <a
href="https://github.com/sveltejs/kit#readme"
target="_blank"
rel="noreferrer">SvelteKit</a
>, the official Svelte app framework powered by Vite!
</p> </p>
<p class="read-the-docs"> {#if account}
Click on the Vite and Svelte logos to learn more <p>
Username: {account.username}, ID: {account.id}
</p> </p>
{/if}
<p class="read-the-docs">Click on the Vite and Svelte logos to learn more</p>
</main> </main>
<style> <style>

View file

@ -0,0 +1,9 @@
export interface Account {
id: string;
username: string;
domain: string | null;
}
export interface MeAccount extends Account {
email: string;
}

View file

@ -34,7 +34,7 @@ func (s *TokenStore) GetApplication(ctx context.Context, id ulid.ULID) (database
return app, errors.Wrap(err, "executing query") return app, errors.Wrap(err, "executing query")
} }
func (s *TokenStore) Create(ctx context.Context, userID, appID ulid.ULID, scopes []string, expires time.Time) (database.Token, error) { func (s *TokenStore) Create(ctx context.Context, userID, appID ulid.ULID, scopes database.TokenScopes, expires time.Time) (database.Token, error) {
q := sqlf.Sprintf(`INSERT INTO tokens q := sqlf.Sprintf(`INSERT INTO tokens
(id, user_id, app_id, scopes, expires) (id, user_id, app_id, scopes, expires)
values (%s, %s, %s, %v, %v) values (%s, %s, %s, %v, %v)

View file

@ -19,7 +19,7 @@ type Token struct {
ID ulid.ULID ID ulid.ULID
AppID ulid.ULID AppID ulid.ULID
UserID ulid.ULID UserID ulid.ULID
Scopes []string Scopes TokenScopes
Expires time.Time Expires time.Time
} }
@ -43,3 +43,34 @@ type TokenClaims struct {
UserID ulid.ULID `json:"sub"` UserID ulid.ULID `json:"sub"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
type TokenScope string
const (
// All scopes below
TokenScopeAll TokenScope = "all"
TokenScopeAccountsRead TokenScope = "accounts.read"
// Controls whether tokens have access to sensitive account data, NOT if they can use `/accounts/@me` endpoints.
TokenScopeAccountsMe TokenScope = "accounts.me"
TokenScopeAccountsWrite TokenScope = "accounts.write"
)
func (s TokenScope) IsValid() bool {
switch s {
case TokenScopeAccountsRead, TokenScopeAccountsMe, TokenScopeAccountsWrite:
return true
default:
return false
}
}
type TokenScopes []TokenScope
func (s TokenScopes) Has(scope TokenScope) bool {
for i := range s {
if s[i] == scope || s[i] == TokenScopeAll {
return true
}
}
return false
}

33
web/api/user.go Normal file
View file

@ -0,0 +1,33 @@
package api
import (
"git.sleepycat.moe/sam/mercury/internal/database"
"github.com/oklog/ulid/v2"
)
// Account is the basic user returned by endpoints, without any private data.
type Account struct {
ID ulid.ULID `json:"id"`
Username string `json:"username"`
Domain *string `json:"domain"`
}
type SelfAccount struct {
Account
Email string `json:"email"`
}
func DBAccountToAccount(a database.Account) Account {
return Account{
ID: a.ID,
Username: a.Username,
Domain: a.Domain,
}
}
func DBAccountToSelfAccount(a database.Account) SelfAccount {
return SelfAccount{
Account: DBAccountToAccount(a),
Email: *a.Email,
}
}

53
web/app/middleware.go Normal file
View file

@ -0,0 +1,53 @@
package app
import (
"context"
"net/http"
"time"
"git.sleepycat.moe/sam/mercury/internal/database"
)
type ctxKey int
const (
ctxKeyClaims ctxKey = 1
)
func (app *App) FrontendAuth(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(database.TokenCookieName)
if err != nil || cookie.Value == "" {
next.ServeHTTP(w, r)
return
}
token, err := app.ParseToken(r.Context(), cookie.Value)
if err != nil {
app.ErrorTemplate(w, r, "Invalid token", "The provided token was not valid. Try clearing your cookies and reloading the page.")
return
}
if token.Expires.Before(time.Now()) {
http.SetCookie(w, &http.Cookie{Name: database.TokenCookieName, Value: "", Expires: time.Now()})
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), ctxKeyClaims, token)
next.ServeHTTP(w, r.WithContext(ctx))
}
return http.HandlerFunc(fn)
}
func (app *App) TokenFromContext(ctx context.Context) (database.Token, bool) {
v := ctx.Value(ctxKeyClaims)
if v == nil {
return database.Token{}, false
}
claims, ok := v.(database.Token)
return claims, ok
}

View file

@ -7,6 +7,13 @@ import (
"github.com/flosch/pongo2/v6" "github.com/flosch/pongo2/v6"
) )
func (app *App) ErrorTemplate(w http.ResponseWriter, r *http.Request, header, desc string) error {
return app.Template(w, r, "error.tpl", pongo2.Context{
"header": header,
"description": desc,
})
}
func (app *App) Template(w http.ResponseWriter, r *http.Request, tmplName string, ctx pongo2.Context) error { func (app *App) Template(w http.ResponseWriter, r *http.Request, tmplName string, ctx pongo2.Context) error {
tmpl, err := app.tmpl.FromCache(tmplName) tmpl, err := app.tmpl.FromCache(tmplName)
if err != nil { if err != nil {

View file

@ -70,7 +70,7 @@ func (app *Auth) PostLogin(w http.ResponseWriter, r *http.Request) {
// create a new token // create a new token
token, err := app.Token(conn).Create( token, err := app.Token(conn).Create(
ctx, acct.ID, *app.DBConfig.Get().InternalApplication, []string{"all"}, time.Now().Add(math.MaxInt64)) ctx, acct.ID, *app.DBConfig.Get().InternalApplication, database.TokenScopes{database.TokenScopeAll}, time.Now().Add(math.MaxInt64))
if err != nil { if err != nil {
log.Err(err).Msg("creating token") log.Err(err).Msg("creating token")
return return

View file

@ -9,5 +9,7 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="text/json" id="accountData">{{.AccountData}}</script>
</body> </body>
</html> </html>

View file

@ -2,6 +2,7 @@ package frontend
import ( import (
"embed" "embed"
"encoding/json"
"html/template" "html/template"
"net/http" "net/http"
"os" "os"
@ -9,6 +10,7 @@ import (
"git.sleepycat.moe/sam/mercury/frontend" "git.sleepycat.moe/sam/mercury/frontend"
"git.sleepycat.moe/sam/mercury/internal/database" "git.sleepycat.moe/sam/mercury/internal/database"
"git.sleepycat.moe/sam/mercury/web/api"
"git.sleepycat.moe/sam/mercury/web/app" "git.sleepycat.moe/sam/mercury/web/app"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -89,10 +91,65 @@ func (app *Frontend) ServeAssets(w http.ResponseWriter, r *http.Request) {
} }
func (app *Frontend) ServeFrontend(w http.ResponseWriter, r *http.Request) { func (app *Frontend) ServeFrontend(w http.ResponseWriter, r *http.Request) {
_, ok := app.TokenFromContext(r.Context())
if !ok {
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
return
}
app.serveFrontend(w, r)
}
func (app *Frontend) ServeUser(w http.ResponseWriter, r *http.Request) {
_, ok := app.TokenFromContext(r.Context())
if !ok {
username := chi.URLParam(r, "username")
http.Redirect(w, r, "/@"+username, http.StatusSeeOther)
return
}
app.serveFrontend(w, r)
}
func (app *Frontend) ServeStatus(w http.ResponseWriter, r *http.Request) {
_, ok := app.TokenFromContext(r.Context())
if !ok {
username := chi.URLParam(r, "username")
postID := chi.URLParam(r, "postID")
http.Redirect(w, r, "/@"+username+"/posts/"+postID, http.StatusSeeOther)
return
}
app.serveFrontend(w, r)
}
func (app *Frontend) serveFrontend(w http.ResponseWriter, r *http.Request) {
token, ok := app.TokenFromContext(r.Context())
if !ok {
log.Error().Msg("app.serveFrontend was called without a token being set")
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
return
}
acct, err := app.Account().ByID(r.Context(), token.UserID)
if err != nil {
log.Err(err).Msg("fetching account")
app.ErrorTemplate(w, r, "Internal server error", "An internal server error occurred. Please try again later.")
return
}
b, err := json.Marshal(api.DBAccountToSelfAccount(acct))
if err != nil {
log.Err(err).Msg("marshaling account json")
app.ErrorTemplate(w, r, "Internal server error", "An internal server error occurred. Please try again later.")
return
}
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err := app.tmpl.Execute(w, map[string]any{ err = app.tmpl.Execute(w, map[string]any{
"Config": database.DefaultConfig, "Config": database.DefaultConfig,
"Vue": app.glue, "Vue": app.glue,
"AccountData": template.HTML(b),
}) })
if err != nil { if err != nil {
log.Err(err).Msg("executing frontend template") log.Err(err).Msg("executing frontend template")

View file

@ -22,9 +22,13 @@ func Routes(app *app.App) {
// also assets // also assets
app.Router.Group(func(r chi.Router) { app.Router.Group(func(r chi.Router) {
frontend := frontend.New(app) frontend := frontend.New(app)
r.Use(app.FrontendAuth)
r.HandleFunc(frontend.AssetsPath(), frontend.ServeAssets) r.HandleFunc(frontend.AssetsPath(), frontend.ServeAssets)
r.HandleFunc("/static/*", frontend.ServeStaticAssets) r.HandleFunc("/static/*", frontend.ServeStaticAssets)
r.HandleFunc("/web", frontend.ServeFrontend) r.HandleFunc("/web", frontend.ServeFrontend)
r.HandleFunc("/web/*", frontend.ServeFrontend) r.HandleFunc("/web/*", frontend.ServeFrontend)
r.HandleFunc("/web/@{username}", frontend.ServeUser)
r.HandleFunc("/web/@{username}/posts/{postID}", frontend.ServeStatus)
}) })
} }

10
web/templates/error.tpl Normal file
View file

@ -0,0 +1,10 @@
{% extends 'base.tpl' %}
{% block title %}
Error &bull; {{ config.Name }}
{% endblock %}
{% block content %}
<div class="error">
<h1>{{ heading }}</h1>
<p>{{ description }}</p>
</div>
{% endblock %}