add frontend auth middleware, embed user data in frontend html
This commit is contained in:
parent
d8cb8c8fa8
commit
0fa769a248
12 changed files with 265 additions and 42 deletions
|
@ -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>
|
||||||
|
|
9
frontend/src/lib/api/account.ts
Normal file
9
frontend/src/lib/api/account.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
domain: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeAccount extends Account {
|
||||||
|
email: string;
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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
33
web/api/user.go
Normal 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
53
web/app/middleware.go
Normal 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
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
10
web/templates/error.tpl
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{% extends 'base.tpl' %}
|
||||||
|
{% block title %}
|
||||||
|
Error • {{ config.Name }}
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="error">
|
||||||
|
<h1>{{ heading }}</h1>
|
||||||
|
<p>{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
Loading…
Reference in a new issue