Implement OIDC single sign-on #27

Merged
yequari merged 5 commits from sso into dev 2025-07-19 22:37:22 +00:00
21 changed files with 756 additions and 105 deletions

5
.gitignore vendored
View File

@ -31,4 +31,7 @@ tls/
test.db.old
.gitignore
.nvim/session
*templ.txt
*templ.txt
# env files
.env*

View File

@ -1,9 +1,12 @@
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"git.32bit.cafe/32bitcafe/guestbook/internal/assert"
@ -150,9 +153,6 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
ts := newTestServer(t, app.routes())
defer ts.Close()
_, _, body := ts.get(t, fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(1)))
validCSRFToken := extractCSRFToken(t, body)
const (
validAuthorName = "John Test"
validAuthorEmail = "test@example.com"
@ -166,8 +166,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
authorEmail string
authorSite string
content string
csrfToken string
wantCode int
wantBody string
}{
{
name: "Valid input",
@ -175,8 +175,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
authorEmail: validAuthorEmail,
authorSite: validAuthorSite,
content: validContent,
csrfToken: validCSRFToken,
wantCode: http.StatusSeeOther,
wantCode: http.StatusOK,
wantBody: "Comment successfully posted",
},
{
name: "Blank name",
@ -184,8 +184,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
authorEmail: validAuthorEmail,
authorSite: validAuthorSite,
content: validContent,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantCode: http.StatusOK,
wantBody: "An error occurred",
},
{
name: "Blank email",
@ -193,8 +193,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
authorEmail: "",
authorSite: validAuthorSite,
content: validContent,
csrfToken: validCSRFToken,
wantCode: http.StatusSeeOther,
wantCode: http.StatusOK,
wantBody: "Comment successfully posted",
},
{
name: "Blank site",
@ -202,8 +202,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
authorEmail: validAuthorEmail,
authorSite: "",
content: validContent,
csrfToken: validCSRFToken,
wantCode: http.StatusSeeOther,
wantCode: http.StatusOK,
wantBody: "Comment successfully posted",
},
{
name: "Blank content",
@ -211,21 +211,39 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
authorEmail: validAuthorEmail,
authorSite: validAuthorSite,
content: "",
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantCode: http.StatusOK,
wantBody: "An error occurred",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
form := url.Values{}
form.Add("authorname", tt.authorName)
form.Add("authoremail", tt.authorEmail)
form.Add("authorsite", tt.authorSite)
form.Add("content", tt.content)
form.Add("csrf_token", tt.csrfToken)
code, _, body := ts.postForm(t, fmt.Sprintf("/websites/%s/guestbook/comments/create/remote", shortIdToSlug(1)), form)
assert.Equal(t, code, tt.wantCode)
assert.Equal(t, body, body)
r, err := http.NewRequest("POST", ts.URL, strings.NewReader(form.Encode()))
if err != nil {
t.Fatal(err)
}
r.URL.Path = fmt.Sprintf("/websites/%s/guestbook/comments/create/remote", shortIdToSlug(1))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.Header.Set("Origin", "http://example.com")
resp, err := ts.Client().Do(r)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
body = bytes.TrimSpace(body)
assert.Equal(t, resp.StatusCode, tt.wantCode)
assert.StringContains(t, string(body), tt.wantBody)
})
}
}

View File

@ -9,15 +9,22 @@ import (
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
"git.32bit.cafe/32bitcafe/guestbook/internal/validator"
"git.32bit.cafe/32bitcafe/guestbook/ui/views"
"github.com/coreos/go-oidc/v3/oidc"
)
func (app *application) getUserRegister(w http.ResponseWriter, r *http.Request) {
if !app.config.localAuthEnabled {
http.Redirect(w, r, "/users/login/oidc", http.StatusFound)
}
form := forms.UserRegistrationForm{}
data := app.newCommonData(r)
views.UserRegistration("User Registration", data, form).Render(r.Context(), w)
}
func (app *application) getUserLogin(w http.ResponseWriter, r *http.Request) {
if !app.config.localAuthEnabled {
http.Redirect(w, r, "/users/login/oidc", http.StatusFound)
}
views.UserLogin("Login", app.newCommonData(r), forms.UserLoginForm{}).Render(r.Context(), w)
}
@ -92,6 +99,113 @@ func (app *application) postUserLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (app *application) userLoginOIDC(w http.ResponseWriter, r *http.Request) {
if !app.config.oauthEnabled {
http.Redirect(w, r, "/users/login", http.StatusFound)
}
state, err := randString(16)
if err != nil {
app.serverError(w, r, err)
return
}
nonce, err := randString(16)
if err != nil {
app.serverError(w, r, err)
return
}
setCallbackCookie(w, r, "state", state)
setCallbackCookie(w, r, "nonce", nonce)
http.Redirect(w, r, app.config.oauth.config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
}
func (app *application) userLoginOIDCCallback(w http.ResponseWriter, r *http.Request) {
state, err := r.Cookie("state")
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
if r.URL.Query().Get("state") != state.Value {
app.clientError(w, http.StatusBadRequest)
return
}
oauth2Token, err := app.config.oauth.config.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
app.logger.Error("Failed to exchange token")
app.serverError(w, r, err)
return
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
app.serverError(w, r, errors.New("No id_token field in oauth2 token"))
return
}
idToken, err := app.config.oauth.verifier.Verify(r.Context(), rawIDToken)
if err != nil {
app.logger.Error("Failed to verify ID token")
app.serverError(w, r, err)
return
}
nonce, err := r.Cookie("nonce")
if err != nil {
app.logger.Error("nonce not found")
app.serverError(w, r, err)
return
}
if idToken.Nonce != nonce.Value {
app.serverError(w, r, errors.New("nonce did not match"))
return
}
oauth2Token.AccessToken = "*REDACTED*"
var t models.UserIdToken
if err := idToken.Claims(&t); err != nil {
app.serverError(w, r, err)
return
}
err = app.sessionManager.RenewToken(r.Context())
if err != nil {
app.serverError(w, r, err)
return
}
// search for user by subject
id, err := app.users.GetBySubject(t.Subject)
if err != nil && errors.Is(err, models.ErrNoRecord) {
// if no user is found, check if they have signed up by email already
id, err = app.users.GetByEmail(t.Email)
if err == nil {
// if user is found by email, update subject to match them in the first step next time
err2 := app.users.UpdateSubject(id, t.Subject)
if err2 != nil {
app.serverError(w, r, err2)
return
}
}
} else if err != nil {
app.serverError(w, r, err)
return
}
if err != nil && errors.Is(err, models.ErrNoRecord) {
// if no user is found by subject or email, create a new user
id, err = app.users.InsertWithoutPassword(app.createShortId(), t.Username, t.Email, t.Subject, DefaultUserSettings())
if err != nil {
app.serverError(w, r, err)
}
} else if err != nil {
app.serverError(w, r, err)
return
}
app.sessionManager.Put(r.Context(), "authenticatedUserId", id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (app *application) postUserLogout(w http.ResponseWriter, r *http.Request) {
err := app.sessionManager.RenewToken(r.Context())
if err != nil {

View File

@ -1,11 +1,17 @@
package main
import (
"context"
"crypto/rsa"
"net/http"
"net/url"
"testing"
"time"
"git.32bit.cafe/32bitcafe/guestbook/internal/assert"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc/oidctest"
"golang.org/x/oauth2"
)
func TestUserSignup(t *testing.T) {
@ -119,3 +125,162 @@ func TestUserSignup(t *testing.T) {
}
}
type OAuth2Mock struct {
Srv *testServer
Priv *rsa.PrivateKey
Subject string
Email string
}
func (o *OAuth2Mock) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
return ""
}
func (o *OAuth2Mock) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
tkn := oauth2.Token{
AccessToken: "AccessToken",
Expiry: time.Now().Add(1 * time.Hour),
}
m := make(map[string]any)
var rawClaims = `{
"iss": "` + o.Srv.URL + `",
"aud": "my-client-id",
"sub": "` + o.Subject + `",
"email": "` + o.Email + `",
"email_verified": true,
"nonce": "nonce"
}`
m["id_token"] = oidctest.SignIDToken(o.Priv, "test-key", oidc.RS256, rawClaims)
return tkn.WithExtra(m), nil
}
func (o *OAuth2Mock) Client(ctx context.Context, t *oauth2.Token) *http.Client {
return nil
}
func TestUserOIDCCallback(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
priv := newTestKey(t)
srv := newTestOIDCServer(t, priv)
defer srv.Close()
defer ts.Close()
ctx := context.Background()
p, err := oidc.NewProvider(ctx, srv.URL)
if err != nil {
t.Fatal(err)
}
cfg := &oidc.Config{
ClientID: "my-client-id",
SkipExpiryCheck: true,
}
v := p.VerifierContext(ctx, cfg)
oMock := &OAuth2Mock{
Srv: srv,
Priv: priv,
Subject: "foo",
}
app.config.oauth = applicationOauthConfig{
ctx: context.Background(),
oidcConfig: cfg,
config: oMock,
provider: p,
verifier: v,
}
app.config.oauthEnabled = true
const (
validSubject = "goodSubject"
unknownSubject = "foo"
validUserId = 1
validEmail = "test@example.com"
validState = "goodState"
)
tests := []struct {
name string
subject string
email string
state string
wantCode int
}{
{
name: "By Subject",
subject: validSubject,
email: "",
state: validState,
wantCode: http.StatusSeeOther,
},
{
name: "By Email",
subject: unknownSubject,
email: validEmail,
state: validState,
wantCode: http.StatusSeeOther,
},
{
name: "No User",
subject: unknownSubject,
email: "",
state: validState,
wantCode: http.StatusSeeOther,
},
{
name: "Invalid State",
subject: unknownSubject,
email: validEmail,
state: "",
wantCode: http.StatusInternalServerError,
},
{
name: "Unknown Subject & Email",
subject: unknownSubject,
email: "",
state: validState,
wantCode: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(*testing.T) {
oMock.Subject = tt.subject
oMock.Email = tt.email
r, err := http.NewRequest("GET", ts.URL, nil)
if err != nil {
t.Fatal(err)
}
r.URL.Path = "/users/login/oidc/callback"
q := r.URL.Query()
q.Add("state", tt.state)
r.URL.RawQuery = q.Encode()
c := &http.Cookie{
Name: "state",
Value: validState,
MaxAge: int(time.Hour.Seconds()),
Secure: r.TLS != nil,
HttpOnly: true,
}
d := &http.Cookie{
Name: "nonce",
Value: "nonce",
MaxAge: int(time.Hour.Seconds()),
Secure: r.TLS != nil,
HttpOnly: true,
}
r.AddCookie(c)
r.AddCookie(d)
resp, err := ts.Client().Do(r)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, resp.StatusCode, tt.wantCode)
})
}
}

View File

@ -1,8 +1,11 @@
package main
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
@ -104,13 +107,15 @@ func (app *application) getCurrentUser(r *http.Request) *models.User {
func (app *application) newCommonData(r *http.Request) views.CommonData {
return views.CommonData{
CurrentYear: time.Now().Year(),
Flash: app.sessionManager.PopString(r.Context(), "flash"),
IsAuthenticated: app.isAuthenticated(r),
CSRFToken: nosurf.Token(r),
CurrentUser: app.getCurrentUser(r),
IsHtmx: r.Header.Get("Hx-Request") == "true",
RootUrl: app.rootUrl,
CurrentYear: time.Now().Year(),
Flash: app.sessionManager.PopString(r.Context(), "flash"),
IsAuthenticated: app.isAuthenticated(r),
CSRFToken: nosurf.Token(r),
CurrentUser: app.getCurrentUser(r),
IsHtmx: r.Header.Get("Hx-Request") == "true",
RootUrl: app.config.rootUrl,
LocalAuthEnabled: app.config.localAuthEnabled,
OIDCEnabled: app.config.oauthEnabled,
}
}
@ -140,3 +145,22 @@ func matchOrigin(origin string, u *url.URL) bool {
}
return true
}
func randString(nByte int) (string, error) {
b := make([]byte, nByte)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
c := &http.Cookie{
Name: name,
Value: value,
MaxAge: int(time.Hour.Seconds()),
Secure: r.TLS != nil,
HttpOnly: true,
}
http.SetCookie(w, c)
}

View File

@ -1,23 +1,47 @@
package main
import (
"context"
"crypto/tls"
"database/sql"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"unicode"
"git.32bit.cafe/32bitcafe/guestbook/internal/auth"
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
"github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gorilla/schema"
"github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3"
"golang.org/x/oauth2"
)
type applicationOauthConfig struct {
ctx context.Context
oidcConfig *oidc.Config
config auth.OAuth2ConfigInterface
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
}
type applicationConfig struct {
oauthEnabled bool
localAuthEnabled bool
oauth applicationOauthConfig
rootUrl string
}
type application struct {
sequence uint16
logger *slog.Logger
@ -26,19 +50,29 @@ type application struct {
guestbookComments models.GuestbookCommentModelInterface
sessionManager *scs.SessionManager
formDecoder *schema.Decoder
config applicationConfig
debug bool
timezones []string
rootUrl string
}
func main() {
addr := flag.String("addr", ":3000", "HTTP network address")
dsn := flag.String("dsn", "guestbook.db", "data source name")
debug := flag.Bool("debug", false, "enable debug mode")
root := flag.String("root", "localhost:3000", "root URL of application")
env := flag.String("env", ".env", ".env file path")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
err := godotenv.Load(*env)
if err != nil {
logger.Error(err.Error())
os.Exit(1)
}
cfg, err := setupConfig(*addr)
if err != nil {
logger.Error(err.Error())
os.Exit(1)
}
db, err := openDB(*dsn)
if err != nil {
@ -62,9 +96,9 @@ func main() {
users: &models.UserModel{DB: db, Settings: make(map[string]models.Setting)},
guestbookComments: &models.GuestbookCommentModel{DB: db},
formDecoder: formDecoder,
config: cfg,
debug: *debug,
timezones: getAvailableTimezones(),
rootUrl: *root,
}
err = app.users.InitializeSettingsMap()
@ -114,6 +148,75 @@ func openDB(dsn string) (*sql.DB, error) {
return db, nil
}
func setupConfig(addr string) (applicationConfig, error) {
var c applicationConfig
var (
rootUrl = os.Getenv("ROOT_URL")
oidcEnabled = os.Getenv("ENABLE_OIDC")
localLoginEnabled = os.Getenv("ENABLE_LOCAL_LOGIN")
oauth2Provider = os.Getenv("OAUTH2_PROVIDER")
clientID = os.Getenv("OAUTH2_CLIENT_ID")
clientSecret = os.Getenv("OAUTH2_CLIENT_SECRET")
)
if rootUrl != "" {
c.rootUrl = rootUrl
} else {
u, err := url.Parse(fmt.Sprintf("https://localhost%s", addr))
if err != nil {
return c, err
}
c.rootUrl = u.String()
}
oauthEnabled, err := strconv.ParseBool(oidcEnabled)
if err != nil {
c.oauthEnabled = false
}
c.oauthEnabled = oauthEnabled
localAuthEnabled, err := strconv.ParseBool(localLoginEnabled)
if err != nil {
c.localAuthEnabled = true
}
c.localAuthEnabled = localAuthEnabled
if !c.oauthEnabled && !c.localAuthEnabled {
return c, errors.New("Either ENABLE_OIDC or ENABLE_LOCAL_LOGIN must be set to true")
}
// if OIDC is disabled, no more configuration needs to be read
if !oauthEnabled {
return c, nil
}
var o applicationOauthConfig
if oauth2Provider == "" || clientID == "" || clientSecret == "" {
return c, errors.New("OAUTH2_PROVIDER, OAUTH2_CLIENT_ID, and OAUTH2_CLIENT_SECRET must be specified as environment variables.")
}
o.ctx = context.Background()
provider, err := oidc.NewProvider(o.ctx, oauth2Provider)
if err != nil {
return c, err
}
o.provider = provider
o.oidcConfig = &oidc.Config{
ClientID: clientID,
}
o.verifier = provider.Verifier(o.oidcConfig)
o.config = &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: fmt.Sprintf("%s/users/login/oidc/callback", c.rootUrl),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
c.oauth = o
return c, nil
}
func getAvailableTimezones() []string {
var zones []string
var zoneDirs = []string{

View File

@ -27,6 +27,8 @@ func (app *application) routes() http.Handler {
mux.Handle("POST /users/register", dynamic.ThenFunc(app.postUserRegister))
mux.Handle("GET /users/login", dynamic.ThenFunc(app.getUserLogin))
mux.Handle("POST /users/login", dynamic.ThenFunc(app.postUserLogin))
mux.Handle("/users/login/oidc", dynamic.ThenFunc(app.userLoginOIDC))
mux.Handle("/users/login/oidc/callback", dynamic.ThenFunc(app.userLoginOIDCCallback))
mux.Handle("GET /help", dynamic.ThenFunc(app.notImplemented))
protected := dynamic.Append(app.requireAuthentication)

View File

@ -2,6 +2,8 @@ package main
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"html"
"io"
"log/slog"
@ -15,6 +17,8 @@ import (
"git.32bit.cafe/32bitcafe/guestbook/internal/models/mocks"
"github.com/alexedwards/scs/v2"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc/oidctest"
"github.com/gorilla/schema"
)
@ -34,9 +38,35 @@ func newTestApplication(t *testing.T) *application {
guestbookComments: &mocks.GuestbookCommentModel{},
formDecoder: formDecoder,
timezones: getAvailableTimezones(),
config: applicationConfig{
localAuthEnabled: true,
},
}
}
func newTestKey(t *testing.T) *rsa.PrivateKey {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
return priv
}
func newTestOIDCServer(t *testing.T, priv *rsa.PrivateKey) *testServer {
s := &oidctest.Server{
PublicKeys: []oidctest.PublicKey{
{
PublicKey: priv.Public(),
KeyID: "test-key",
Algorithm: oidc.ES256,
},
},
}
ts := httptest.NewServer(s)
s.SetIssuer(ts.URL)
return &testServer{ts}
}
type testServer struct {
*httptest.Server
}

5
go.mod
View File

@ -6,9 +6,14 @@ require (
github.com/a-h/templ v0.3.833
github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c
github.com/alexedwards/scs/v2 v2.8.0
github.com/coreos/go-oidc/v3 v3.14.1
github.com/gorilla/schema v1.4.1
github.com/joho/godotenv v1.5.1
github.com/justinas/alice v1.2.0
github.com/justinas/nosurf v1.1.1
github.com/mattn/go-sqlite3 v1.14.24
golang.org/x/crypto v0.36.0
golang.org/x/oauth2 v0.30.0
)
require github.com/go-jose/go-jose/v4 v4.0.5 // indirect

21
go.sum
View File

@ -1,24 +1,35 @@
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 h1:+DCxWg/ojncqS+TGAuRUoV7OfG/S4doh0pcpAwEcow0=
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c h1:0gBCIsmH3+aaWK55APhhY7/Z+uv5IdbMqekI97V9shU=
github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk=
github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

14
internal/auth/auth.go Normal file
View File

@ -0,0 +1,14 @@
package auth
import (
"context"
"net/http"
"golang.org/x/oauth2"
)
type OAuth2ConfigInterface interface {
AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
Client(ctx context.Context, t *oauth2.Token) *http.Client
}

View File

@ -1,6 +1,7 @@
package mocks
import (
"errors"
"time"
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
@ -38,6 +39,15 @@ func (m *UserModel) Insert(shortId uint64, username string, email string, passwo
}
}
func (m *UserModel) InsertWithoutPassword(shortId uint64, username string, email string, password string, settings models.UserSettings) (int64, error) {
switch email {
case "dupe@example.com":
return -1, models.ErrDuplicateEmail
default:
return 2, nil
}
}
func (m *UserModel) Get(shortId uint64) (models.User, error) {
switch shortId {
case 1:
@ -87,3 +97,26 @@ func (m *UserModel) UpdateUserSettings(userId int64, settings models.UserSetting
func (m *UserModel) UpdateSetting(userId int64, setting models.Setting, value string) error {
return nil
}
func (m *UserModel) GetBySubject(subject string) (int64, error) {
if subject == "goodSubject" {
return 1, nil
} else if subject == "foo" {
return -1, models.ErrNoRecord
}
return -1, errors.New("Unexpected Error")
}
func (m *UserModel) GetByEmail(email string) (int64, error) {
if email == "test@example.com" {
return 1, nil
}
return -1, models.ErrNoRecord
}
func (m *UserModel) UpdateSubject(userId int64, subject string) error {
if userId == 1 {
return nil
}
return errors.New("invalid")
}

View File

@ -35,16 +35,28 @@ type UserModel struct {
Settings map[string]Setting
}
type UserIdToken struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Username string `json:"preferred_username"`
Groups []string `json:"groups"`
}
type UserModelInterface interface {
InitializeSettingsMap() error
Insert(shortId uint64, username string, email string, password string, settings UserSettings) error
InsertWithoutPassword(shortId uint64, username string, email string, subject string, settings UserSettings) (int64, error)
Get(shortId uint64) (User, error)
GetById(id int64) (User, error)
GetByEmail(email string) (int64, error)
GetBySubject(subject string) (int64, error)
GetAll() ([]User, error)
Authenticate(email, password string) (int64, error)
Exists(id int64) (bool, error)
UpdateUserSettings(userId int64, settings UserSettings) error
UpdateSetting(userId int64, setting Setting, value string) error
UpdateSubject(userId int64, subject string) error
}
func (m *UserModel) InitializeSettingsMap() error {
@ -126,6 +138,49 @@ func (m *UserModel) Insert(shortId uint64, username string, email string, passwo
return nil
}
func (m *UserModel) InsertWithoutPassword(shortId uint64, username string, email string, subject string, settings UserSettings) (int64, error) {
stmt := `INSERT INTO users (ShortId, Username, Email, IsBanned, OIDCSubject, Created)
VALUES (?, ?, ?, FALSE, ?, ?)`
tx, err := m.DB.Begin()
if err != nil {
return -1, err
}
result, err := tx.Exec(stmt, shortId, username, email, subject, time.Now().UTC())
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
return -1, err
}
if sqliteError, ok := err.(sqlite3.Error); ok {
if sqliteError.ExtendedCode == 2067 && strings.Contains(sqliteError.Error(), "Email") {
return -1, ErrDuplicateEmail
}
}
return -1, err
}
id, err := result.LastInsertId()
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
return -1, err
}
return -1, err
}
stmt = `INSERT INTO user_settings (UserId, SettingId, AllowedSettingValueId, UnconstrainedValue)
VALUES (?, ?, ?, ?)`
_, err = tx.Exec(stmt, id, m.Settings[SettingUserTimezone].id, nil, settings.LocalTimezone.String())
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
return -1, err
}
return -1, err
}
err = tx.Commit()
if err != nil {
return -1, err
}
return id, nil
}
func (m *UserModel) Get(shortId uint64) (User, error) {
stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE ShortId = ? AND Deleted IS NULL`
tx, err := m.DB.Begin()
@ -239,6 +294,44 @@ func (m *UserModel) Authenticate(email, password string) (int64, error) {
return id, nil
}
func (m *UserModel) GetByEmail(email string) (int64, error) {
var id int64
stmt := `SELECT Id FROM users WHERE Email = ?`
err := m.DB.QueryRow(stmt, email).Scan(&id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return -1, ErrNoRecord
} else {
return -1, err
}
}
return id, nil
}
func (m *UserModel) GetBySubject(subject string) (int64, error) {
var id int64
var s sql.NullString
stmt := `SELECT Id, OIDCSubject FROM users WHERE OIDCSubject = ?`
err := m.DB.QueryRow(stmt, subject).Scan(&id, &s)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return -1, ErrNoRecord
} else {
return -1, err
}
}
return id, nil
}
func (m *UserModel) UpdateSubject(userId int64, subject string) error {
stmt := `UPDATE users SET OIDCSubject = ? WHERE Id = ?`
_, err := m.DB.Exec(stmt, subject, userId)
if err != nil {
return err
}
return nil
}
func (m *UserModel) Exists(id int64) (bool, error) {
var exists bool
stmt := `SELECT EXISTS(SELECT true FROM users WHERE Id = ? AND DELETED IS NULL)`

View File

@ -0,0 +1,4 @@
ALTER TABLE users RENAME COLUMN HashedPassword TO HashedPasswordOld;
ALTER TABLE users ADD COLUMN HashedPassword char(60) NOT NULL DEFAULT '0000';
UPDATE users SET HashedPassword=HashedPasswordOld;
ALTER TABLE users DROP COLUMN HashedPasswordOld;

View File

@ -0,0 +1,4 @@
ALTER TABLE users RENAME COLUMN HashedPassword TO HashedPasswordOld;
ALTER TABLE users ADD COLUMN HashedPassword char(60) NULL;
UPDATE users SET HashedPassword=HashedPasswordOld;
ALTER TABLE users DROP COLUMN HashedPasswordOld;

View File

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN OIDCSubject;

View File

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN OIDCSubject varchar(255);

View File

@ -6,12 +6,15 @@ import "fmt"
import "strings"
type CommonData struct {
CurrentYear int
Flash string
IsAuthenticated bool
CSRFToken string
CurrentUser *models.User
IsHtmx bool
CurrentYear int
Flash string
IsAuthenticated bool
CSRFToken string
CurrentUser *models.User
IsHtmx bool
RootUrl string
LocalAuthEnabled bool
OIDCEnabled bool
}
func shortIdToSlug(shortId uint64) string {
@ -51,7 +54,9 @@ templ topNav(data CommonData) {
<a href="/users/settings">Settings</a> |
<a href="#" hx-post="/users/logout" hx-headers={ hxHeaders }>Logout</a>
} else {
<a href="/users/register">Create an Account</a> |
if data.LocalAuthEnabled {
<a href="/users/register">Create an Account</a> |
}
<a href="/users/login">Login</a>
}
</div>

View File

@ -14,13 +14,15 @@ import "fmt"
import "strings"
type CommonData struct {
CurrentYear int
Flash string
IsAuthenticated bool
CSRFToken string
CurrentUser *models.User
IsHtmx bool
RootUrl string
CurrentYear int
Flash string
IsAuthenticated bool
CSRFToken string
CurrentUser *models.User
IsHtmx bool
RootUrl string
LocalAuthEnabled bool
OIDCEnabled bool
}
func shortIdToSlug(shortId uint64) string {
@ -102,7 +104,7 @@ func topNav(data CommonData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentUser.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 44, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 47, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@ -121,7 +123,7 @@ func topNav(data CommonData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(hxHeaders)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 52, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 55, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@ -132,12 +134,18 @@ func topNav(data CommonData) templ.Component {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"/users/register\">Create an Account</a> | <a href=\"/users/login\">Login</a>")
if data.LocalAuthEnabled {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"/users/register\">Create an Account</a> | ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " <a href=\"/users/login\">Login</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></nav>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -166,7 +174,7 @@ func commonFooter() templ.Component {
templ_7745c5c3_Var5 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<footer><p>A <a href=\"https://32bit.cafe\">32bit.cafe</a> Project</p></footer>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<footer><p>A <a href=\"https://32bit.cafe\">32bit.cafe</a> Project</p></footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -195,20 +203,20 @@ func base(title string, data CommonData) templ.Component {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<!doctype html><html lang=\"en\"><head><title>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<!doctype html><html lang=\"en\"><head><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 71, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 76, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " - webweav.ing</title><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"/static/css/classless.min.css\" rel=\"stylesheet\"><link href=\"/static/css/style.css\" rel=\"stylesheet\"><script src=\"/static/js/htmx.min.js\"></script></head><body>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " - webweav.ing</title><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"/static/css/classless.min.css\" rel=\"stylesheet\"><link href=\"/static/css/style.css\" rel=\"stylesheet\"><script src=\"/static/js/htmx.min.js\"></script></head><body>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -220,25 +228,25 @@ func base(title string, data CommonData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<main>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Flash != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"flash\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"flash\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Flash)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 83, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 88, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -247,7 +255,7 @@ func base(title string, data CommonData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</main>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -255,7 +263,7 @@ func base(title string, data CommonData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@ -30,7 +30,10 @@ templ UserLogin(title string, data CommonData, form forms.UserLoginForm) {
<input type="password" name="password"/>
</div>
<div>
<input type="submit" value="login"/>
<input type="submit" value="Login"/>
if data.OIDCEnabled {
<a href="/users/login/oidc">Login with SSO</a>
}
</div>
</form>
}

View File

@ -143,7 +143,17 @@ func UserLogin(title string, data CommonData, form forms.UserLoginForm) templ.Co
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<input type=\"password\" name=\"password\"></div><div><input type=\"submit\" value=\"login\"></div></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<input type=\"password\" name=\"password\"></div><div><input type=\"submit\" value=\"Login\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.OIDCEnabled {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"/users/login/oidc\">Login with SSO</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -190,130 +200,130 @@ func UserRegistration(title string, data CommonData, form forms.UserRegistration
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<h1>User Registration</h1><form action=\"/users/register\" method=\"post\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<h1>User Registration</h1><form action=\"/users/register\" method=\"post\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 43, Col: 64}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 46, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"><div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"><div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
error, exists := form.FieldErrors["name"]
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"username\">Username: </label> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<label for=\"username\">Username: </label> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if exists {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<label class=\"error\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<label class=\"error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(error)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 48, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 51, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</label> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</label> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<input type=\"text\" name=\"username\" id=\"username\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<input type=\"text\" name=\"username\" id=\"username\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 50, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 53, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" required></div><div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" required></div><div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
error, exists = form.FieldErrors["email"]
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<label for=\"email\">Email: </label> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"email\">Email: </label> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if exists {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<label class=\"error\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<label class=\"error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(error)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 56, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 59, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</label> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</label> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<input type=\"text\" name=\"email\" id=\"email\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<input type=\"text\" name=\"email\" id=\"email\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(form.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 58, Col: 65}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 61, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" required></div><div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" required></div><div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
error, exists = form.FieldErrors["password"]
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<label for=\"password\">Password: </label> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<label for=\"password\">Password: </label> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if exists {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<label class=\"error\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<label class=\"error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(error)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 64, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 67, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</label> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</label> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<input type=\"password\" name=\"password\" id=\"password\"></div><div><input type=\"submit\" value=\"Register\"></div></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<input type=\"password\" name=\"password\" id=\"password\"></div><div><input type=\"submit\" value=\"Register\"></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -360,33 +370,33 @@ func UserProfile(title string, data CommonData, user models.User) templ.Componen
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<h1>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 77, Col: 21}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 80, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</h1><p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h1><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 78, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 81, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -434,89 +444,89 @@ func UserSettingsView(data CommonData, timezones []string) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div><h1>User Settings</h1><form hx-put=\"/users/settings\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div><h1>User Settings</h1><form hx-put=\"/users/settings\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 88, Col: 65}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 91, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"> <label>Local Timezone</label> <select name=\"timezones\" id=\"timezone-select\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\"> <label>Local Timezone</label> <select name=\"timezones\" id=\"timezone-select\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, tz := range timezones {
if tz == user.Settings.LocalTimezone.String() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<option value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 93, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 96, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" selected=\"true\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" selected=\"true\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 93, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 96, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</option>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<option value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 95, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 98, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 95, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 98, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</option>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</select> <input type=\"submit\" value=\"Submit\"></form></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</select> <input type=\"submit\" value=\"Submit\"></form></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}