add working signup + login
This commit is contained in:
parent
bc85b7c340
commit
d8cb8c8fa8
27 changed files with 600 additions and 39 deletions
|
@ -1,8 +1,13 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"git.sleepycat.moe/sam/mercury/config"
|
||||
"git.sleepycat.moe/sam/mercury/internal/concurrent"
|
||||
"git.sleepycat.moe/sam/mercury/internal/database"
|
||||
"git.sleepycat.moe/sam/mercury/internal/database/sql"
|
||||
"git.sleepycat.moe/sam/mercury/web/templates"
|
||||
"github.com/flosch/pongo2/v6"
|
||||
|
@ -10,31 +15,54 @@ import (
|
|||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
const (
|
||||
ErrSecretKeyEmpty = errors.Sentinel("core.secret_key cannot be empty")
|
||||
)
|
||||
|
||||
type App struct {
|
||||
Router chi.Router
|
||||
|
||||
Config config.Config
|
||||
Database *sql.Base
|
||||
AppConfig config.Config
|
||||
DBConfig *concurrent.Value[database.Config]
|
||||
Database *sql.Base
|
||||
|
||||
tmpl *pongo2.TemplateSet
|
||||
tmpl *pongo2.TemplateSet
|
||||
tokenKey []byte
|
||||
}
|
||||
|
||||
func NewApp(cfg config.Config, db *sql.Base) (*App, error) {
|
||||
func NewApp(ctx context.Context, cfg config.Config, db *sql.Base) (*App, error) {
|
||||
app := &App{
|
||||
Router: chi.NewRouter(),
|
||||
Config: cfg,
|
||||
Database: db,
|
||||
Router: chi.NewRouter(),
|
||||
AppConfig: cfg,
|
||||
Database: db,
|
||||
}
|
||||
|
||||
if cfg.Core.SecretKey == "" {
|
||||
return nil, ErrSecretKeyEmpty
|
||||
}
|
||||
tokenKey, err := base64.RawStdEncoding.DecodeString(cfg.Core.SecretKey)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "decoding core.secret_key")
|
||||
}
|
||||
app.tokenKey = tokenKey
|
||||
|
||||
tmpl, err := templates.New(cfg.Core.Dev)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "creating templates")
|
||||
}
|
||||
app.tmpl = tmpl
|
||||
|
||||
app.Router.Use(app.Logger)
|
||||
if cfg.Core.Dev {
|
||||
app.Router.Use(app.Logger)
|
||||
}
|
||||
app.Router.Use(middleware.Recoverer)
|
||||
|
||||
dbCfg, err := app.Config().Get(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getting database config")
|
||||
}
|
||||
app.DBConfig = concurrent.NewValue(dbCfg)
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
|
@ -52,9 +80,23 @@ func (a *App) Blog(q ...sql.Querier) *sql.BlogStore {
|
|||
return sql.NewBlogStore(q[0])
|
||||
}
|
||||
|
||||
func (a *App) Config(q ...sql.Querier) *sql.ConfigStore {
|
||||
if len(q) == 0 || q[0] == nil {
|
||||
return sql.NewConfigStore(a.Database.PoolQuerier())
|
||||
}
|
||||
return sql.NewConfigStore(q[0])
|
||||
}
|
||||
|
||||
func (a *App) Post(q ...sql.Querier) *sql.PostStore {
|
||||
if len(q) == 0 || q[0] == nil {
|
||||
return sql.NewPostStore(a.Database.PoolQuerier())
|
||||
}
|
||||
return sql.NewPostStore(q[0])
|
||||
}
|
||||
|
||||
func (a *App) Token(q ...sql.Querier) *sql.TokenStore {
|
||||
if len(q) == 0 || q[0] == nil {
|
||||
return sql.NewTokenStore(a.Database.PoolQuerier())
|
||||
}
|
||||
return sql.NewTokenStore(q[0])
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ func (a *App) Logger(next http.Handler) http.Handler {
|
|||
}
|
||||
|
||||
// log end request
|
||||
log.Info().
|
||||
log.Debug().
|
||||
Timestamp().
|
||||
Str("remote_ip", r.RemoteAddr).
|
||||
Str("url", r.URL.Path).
|
||||
|
|
|
@ -14,6 +14,7 @@ func (app *App) Template(w http.ResponseWriter, r *http.Request, tmplName string
|
|||
}
|
||||
|
||||
tctx := pongo2.Context{
|
||||
"config": app.DBConfig.Get(),
|
||||
"flash_message": app.getFlash(w, r),
|
||||
}
|
||||
tctx.Update(ctx)
|
||||
|
|
41
web/app/token.go
Normal file
41
web/app/token.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"git.sleepycat.moe/sam/mercury/internal/database"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
func (app *App) TokenToJWT(token database.Token) (string, error) {
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, token.ToClaims())
|
||||
|
||||
return t.SignedString(app.tokenKey)
|
||||
}
|
||||
|
||||
func (app *App) ParseToken(ctx context.Context, token string) (t database.Token, err error) {
|
||||
parsed, err := jwt.ParseWithClaims(token, &database.TokenClaims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf(`unexpected signing method "%v"`, t.Header["alg"])
|
||||
}
|
||||
|
||||
return app.tokenKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return t, errors.Wrap(err, "parsing token")
|
||||
}
|
||||
|
||||
c, ok := parsed.Claims.(*database.TokenClaims)
|
||||
if !ok || !parsed.Valid {
|
||||
return t, fmt.Errorf("unknown claims type %T", parsed.Claims)
|
||||
}
|
||||
|
||||
t, err = app.Token().Get(ctx, c.TokenID)
|
||||
if err != nil {
|
||||
return t, errors.Wrap(err, "getting token from database")
|
||||
}
|
||||
return t, nil
|
||||
}
|
|
@ -1,11 +1,92 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sleepycat.moe/sam/mercury/internal/database"
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (app *Auth) GetLogin(w http.ResponseWriter, r *http.Request) {
|
||||
app.Template(w, r, "auth/login.tpl", pongo2.Context{})
|
||||
app.Template(w, r, "auth/login.tpl", pongo2.Context{
|
||||
"totp": false,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *Auth) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
if username == "" {
|
||||
app.Flash(w, "Username cannot be empty.")
|
||||
app.Template(w, r, "auth/login.tpl", pongo2.Context{
|
||||
"totp": false,
|
||||
"flash_message": "Username cannot be empty.",
|
||||
})
|
||||
return
|
||||
} else if password == "" {
|
||||
app.Flash(w, "Password cannot be empty.")
|
||||
app.Template(w, r, "auth/login.tpl", pongo2.Context{
|
||||
"totp": false,
|
||||
"flash_message": "Password cannot be empty.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := app.Database.Acquire(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("acquiring database connection")
|
||||
return
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
acct, err := app.Account(conn).ByUsername(ctx, username, "")
|
||||
if err != nil {
|
||||
log.Err(err).Msg("finding account")
|
||||
|
||||
app.Flash(w, "Username or password is invalid.")
|
||||
app.Template(w, r, "auth/login.tpl", pongo2.Context{
|
||||
"totp": false,
|
||||
"flash_message": "Username or password is invalid.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
passwordValid, _ := acct.PasswordValid(password)
|
||||
|
||||
if !passwordValid {
|
||||
app.Template(w, r, "auth/login.tpl", pongo2.Context{
|
||||
"totp": false,
|
||||
"flash_message": "Username or password is invalid.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: totp
|
||||
|
||||
// create a new token
|
||||
token, err := app.Token(conn).Create(
|
||||
ctx, acct.ID, *app.DBConfig.Get().InternalApplication, []string{"all"}, time.Now().Add(math.MaxInt64))
|
||||
if err != nil {
|
||||
log.Err(err).Msg("creating token")
|
||||
return
|
||||
}
|
||||
|
||||
ts, err := app.TokenToJWT(token)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("signing token string")
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: database.TokenCookieName,
|
||||
Value: ts,
|
||||
Path: "/",
|
||||
Expires: token.Expires,
|
||||
})
|
||||
http.Redirect(w, r, "/web", http.StatusSeeOther)
|
||||
}
|
||||
|
|
67
web/auth/signup.go
Normal file
67
web/auth/signup.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sleepycat.moe/sam/mercury/internal/database/sql"
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (app *Auth) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
app.Template(w, r, "auth/signup.tpl", pongo2.Context{})
|
||||
}
|
||||
|
||||
func (app *Auth) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
username := r.FormValue("username")
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
password2 := r.FormValue("repeat_password")
|
||||
if username == "" {
|
||||
app.Template(w, r, "auth/signup.tpl", pongo2.Context{"flash_message": "Username cannot be empty."})
|
||||
return
|
||||
} else if password == "" {
|
||||
app.Template(w, r, "auth/signup.tpl", pongo2.Context{"flash_message": "Password cannot be empty."})
|
||||
return
|
||||
} else if password != password2 {
|
||||
app.Template(w, r, "auth/signup.tpl", pongo2.Context{"flash_message": "Passwords don't match."})
|
||||
return
|
||||
} else if email == "" {
|
||||
app.Template(w, r, "auth/signup.tpl", pongo2.Context{"flash_message": "Email address cannot be empty."})
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := app.Database.BeginTx(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("acquiring database connection")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
acct, err := app.Account(tx).CreateLocal(ctx, username, email, []byte(password))
|
||||
if err != nil {
|
||||
if err == sql.ErrUsernameTaken {
|
||||
app.Template(w, r, "auth/signup.tpl", pongo2.Context{"flash_message": "Username is already taken."})
|
||||
return
|
||||
}
|
||||
log.Err(err).Msg("creating account")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = app.Blog(tx).Create(ctx, acct.ID, username)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("creating blog")
|
||||
return
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("committing transaction")
|
||||
return
|
||||
}
|
||||
|
||||
app.Flash(w, "Successfully created account!")
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
}
|
|
@ -34,7 +34,7 @@ func New(app *app.App) *Frontend {
|
|||
App: app,
|
||||
}
|
||||
|
||||
if app.Config.Core.Dev {
|
||||
if app.AppConfig.Core.Dev {
|
||||
glue, err := vueglue.NewVueGlue(&vueglue.ViteConfig{
|
||||
Environment: "development",
|
||||
AssetsPath: "frontend",
|
||||
|
@ -100,7 +100,7 @@ func (app *Frontend) ServeFrontend(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (app *Frontend) ServeStaticAssets(w http.ResponseWriter, r *http.Request) {
|
||||
if app.Config.Core.Dev {
|
||||
if app.AppConfig.Core.Dev {
|
||||
// TODO: this is unsafe
|
||||
path := filepath.Join("web/frontend/assets/", chi.URLParam(r, "*"))
|
||||
http.ServeFile(w, r, path)
|
||||
|
|
|
@ -12,13 +12,19 @@ func Routes(app *app.App) {
|
|||
app.Router.Route("/auth", func(r chi.Router) {
|
||||
auth := auth.New(app)
|
||||
r.Get("/login", auth.GetLogin)
|
||||
r.Post("/login", auth.PostLogin)
|
||||
|
||||
r.Get("/sign_up", auth.GetSignup)
|
||||
r.Post("/sign_up", auth.PostSignup)
|
||||
})
|
||||
|
||||
// web app handlers
|
||||
// also assets
|
||||
frontend := frontend.New(app)
|
||||
app.Router.HandleFunc(frontend.AssetsPath(), frontend.ServeAssets)
|
||||
app.Router.HandleFunc("/static/*", frontend.ServeStaticAssets)
|
||||
app.Router.HandleFunc("/web", frontend.ServeFrontend)
|
||||
app.Router.HandleFunc("/web/*", frontend.ServeFrontend)
|
||||
app.Router.Group(func(r chi.Router) {
|
||||
frontend := frontend.New(app)
|
||||
r.HandleFunc(frontend.AssetsPath(), frontend.ServeAssets)
|
||||
r.HandleFunc("/static/*", frontend.ServeStaticAssets)
|
||||
r.HandleFunc("/web", frontend.ServeFrontend)
|
||||
r.HandleFunc("/web/*", frontend.ServeFrontend)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,19 +1,33 @@
|
|||
{% extends 'base.tpl' %}
|
||||
{% block title %}
|
||||
Log in
|
||||
Log in • {{ config.Name }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth">
|
||||
<form method="post">
|
||||
<p>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" />
|
||||
</p>
|
||||
<p>
|
||||
<label for="username">Password</label>
|
||||
<input type="password" name="password" />
|
||||
</p>
|
||||
<input type="submit" value="Log in" />
|
||||
</form>
|
||||
{% if flash_message %}
|
||||
<p>{{ flash_message }}</p>
|
||||
{% endif %}
|
||||
{% if not totp %}
|
||||
<form method="post">
|
||||
<p>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" />
|
||||
</p>
|
||||
<p>
|
||||
<label for="username">Password</label>
|
||||
<input type="password" name="password" />
|
||||
</p>
|
||||
<input type="submit" value="Log in" />
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post">
|
||||
<p>
|
||||
<label for="totp_code">Two-factor authentication code</label>
|
||||
<input type="text" name="totp_code" />
|
||||
</p>
|
||||
<input type="text" name="totp_ticket" value="{{ totp_ticket }}" disabled />
|
||||
<input type="submit" value="Log in" />
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
30
web/templates/auth/signup.tpl
Normal file
30
web/templates/auth/signup.tpl
Normal file
|
@ -0,0 +1,30 @@
|
|||
{% extends 'base.tpl' %}
|
||||
{% block title %}
|
||||
Sign up • {{ config.Name }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth">
|
||||
{% if flash_message %}
|
||||
<p>{{ flash_message }}</p>
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
<p>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" />
|
||||
</p>
|
||||
<p>
|
||||
<label for="username">Email</label>
|
||||
<input type="email" name="email" />
|
||||
</p>
|
||||
<p>
|
||||
<label for="username">Password</label>
|
||||
<input type="password" name="password" />
|
||||
</p>
|
||||
<p>
|
||||
<label for="username">Repeat password</label>
|
||||
<input type="password" name="repeat_password" />
|
||||
</p>
|
||||
<input type="submit" value="Sign up" />
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/style.css" />
|
||||
<title>{% block title %}Mercury{% endblock %}</title>
|
||||
<title>{% block title %}{{ config.Name }}{% endblock %}</title>
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue