implement OIDC login flow

This commit is contained in:
yequari 2025-06-29 17:19:35 -07:00
parent 133d8bcfe9
commit 58e6f35585
17 changed files with 333 additions and 26 deletions

5
.gitignore vendored
View File

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

View File

@ -9,6 +9,7 @@ import (
"git.32bit.cafe/32bitcafe/guestbook/internal/models" "git.32bit.cafe/32bitcafe/guestbook/internal/models"
"git.32bit.cafe/32bitcafe/guestbook/internal/validator" "git.32bit.cafe/32bitcafe/guestbook/internal/validator"
"git.32bit.cafe/32bitcafe/guestbook/ui/views" "git.32bit.cafe/32bitcafe/guestbook/ui/views"
"github.com/coreos/go-oidc/v3/oidc"
) )
func (app *application) getUserRegister(w http.ResponseWriter, r *http.Request) { func (app *application) getUserRegister(w http.ResponseWriter, r *http.Request) {
@ -92,6 +93,88 @@ func (app *application) postUserLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
func (app *application) userLoginOIDC(w http.ResponseWriter, r *http.Request) {
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.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.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.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
}
id, err := app.users.AuthenticateByOIDC(t.Email, t.Subject)
if err != nil {
id, err = app.users.InsertWithoutPassword(app.createShortId(), t.Username, t.Email, t.Subject, DefaultUserSettings())
if err != nil {
app.serverError(w, r, err)
}
}
app.sessionManager.Put(r.Context(), "authenticatedUserId", id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (app *application) postUserLogout(w http.ResponseWriter, r *http.Request) { func (app *application) postUserLogout(w http.ResponseWriter, r *http.Request) {
err := app.sessionManager.RenewToken(r.Context()) err := app.sessionManager.RenewToken(r.Context())
if err != nil { if err != nil {

View File

@ -1,8 +1,11 @@
package main package main
import ( import (
"crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io"
"math" "math"
"net/http" "net/http"
"net/url" "net/url"
@ -140,3 +143,22 @@ func matchOrigin(origin string, u *url.URL) bool {
} }
return true 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,9 +1,12 @@
package main package main
import ( import (
"context"
"crypto/tls" "crypto/tls"
"database/sql" "database/sql"
"errors"
"flag" "flag"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@ -14,10 +17,21 @@ import (
"git.32bit.cafe/32bitcafe/guestbook/internal/models" "git.32bit.cafe/32bitcafe/guestbook/internal/models"
"github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gorilla/schema" "github.com/gorilla/schema"
"github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"golang.org/x/oauth2"
) )
type applicationOauthConfig struct {
ctx context.Context
config oauth2.Config
provider *oidc.Provider
oidcConfig *oidc.Config
verifier *oidc.IDTokenVerifier
}
type application struct { type application struct {
sequence uint16 sequence uint16
logger *slog.Logger logger *slog.Logger
@ -26,6 +40,7 @@ type application struct {
guestbookComments models.GuestbookCommentModelInterface guestbookComments models.GuestbookCommentModelInterface
sessionManager *scs.SessionManager sessionManager *scs.SessionManager
formDecoder *schema.Decoder formDecoder *schema.Decoder
oauth applicationOauthConfig
debug bool debug bool
timezones []string timezones []string
rootUrl string rootUrl string
@ -35,10 +50,11 @@ func main() {
addr := flag.String("addr", ":3000", "HTTP network address") addr := flag.String("addr", ":3000", "HTTP network address")
dsn := flag.String("dsn", "guestbook.db", "data source name") dsn := flag.String("dsn", "guestbook.db", "data source name")
debug := flag.Bool("debug", false, "enable debug mode") debug := flag.Bool("debug", false, "enable debug mode")
root := flag.String("root", "localhost:3000", "root URL of application") root := flag.String("root", "https://localhost:3000", "root URL of application")
flag.Parse() flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
godotenv.Load(".env.dev")
db, err := openDB(*dsn) db, err := openDB(*dsn)
if err != nil { if err != nil {
@ -67,6 +83,13 @@ func main() {
rootUrl: *root, rootUrl: *root,
} }
o, err := setupOauth(app.rootUrl)
if err != nil {
logger.Error(err.Error())
os.Exit(1)
}
app.oauth = o
err = app.users.InitializeSettingsMap() err = app.users.InitializeSettingsMap()
if err != nil { if err != nil {
logger.Error(err.Error()) logger.Error(err.Error())
@ -114,6 +137,38 @@ func openDB(dsn string) (*sql.DB, error) {
return db, nil return db, nil
} }
func setupOauth(rootUrl string) (applicationOauthConfig, error) {
var c applicationOauthConfig
var (
oauth2Provider = os.Getenv("OAUTH2_PROVIDER")
clientID = os.Getenv("OAUTH2_CLIENT_ID")
clientSecret = os.Getenv("OAUTH2_CLIENT_SECRET")
)
if oauth2Provider == "" || clientID == "" || clientSecret == "" {
return applicationOauthConfig{}, errors.New("OAUTH2_PROVIDER, OAUTH2_CLIENT_ID, and OAUTH2_CLIENT_SECRET must be specified as environment variables.")
}
c.ctx = context.Background()
provider, err := oidc.NewProvider(c.ctx, oauth2Provider)
if err != nil {
return applicationOauthConfig{}, err
}
c.provider = provider
c.oidcConfig = &oidc.Config{
ClientID: clientID,
}
c.verifier = provider.Verifier(c.oidcConfig)
c.config = oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: fmt.Sprintf("%s/users/login/oidc/callback", rootUrl),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
return c, nil
}
func getAvailableTimezones() []string { func getAvailableTimezones() []string {
var zones []string var zones []string
var zoneDirs = []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("POST /users/register", dynamic.ThenFunc(app.postUserRegister))
mux.Handle("GET /users/login", dynamic.ThenFunc(app.getUserLogin)) mux.Handle("GET /users/login", dynamic.ThenFunc(app.getUserLogin))
mux.Handle("POST /users/login", dynamic.ThenFunc(app.postUserLogin)) 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)) mux.Handle("GET /help", dynamic.ThenFunc(app.notImplemented))
protected := dynamic.Append(app.requireAuthentication) protected := dynamic.Append(app.requireAuthentication)

5
go.mod
View File

@ -6,9 +6,14 @@ require (
github.com/a-h/templ v0.3.833 github.com/a-h/templ v0.3.833
github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c
github.com/alexedwards/scs/v2 v2.8.0 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/gorilla/schema v1.4.1
github.com/joho/godotenv v1.5.1
github.com/justinas/alice v1.2.0 github.com/justinas/alice v1.2.0
github.com/justinas/nosurf v1.1.1 github.com/justinas/nosurf v1.1.1
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
golang.org/x/crypto v0.36.0 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 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk= 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 h1:0gBCIsmH3+aaWK55APhhY7/Z+uv5IdbMqekI97V9shU=
github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss= 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 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= 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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= 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 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= 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 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk=
github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= 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.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 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= 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 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 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=

View File

@ -38,6 +38,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) { func (m *UserModel) Get(shortId uint64) (models.User, error) {
switch shortId { switch shortId {
case 1: case 1:
@ -67,6 +76,13 @@ func (m *UserModel) Authenticate(email, password string) (int64, error) {
return 0, models.ErrInvalidCredentials return 0, models.ErrInvalidCredentials
} }
func (m *UserModel) AuthenticateByOIDC(email, subject string) (int64, error) {
if email == "test@example.com" {
return 1, nil
}
return 0, models.ErrInvalidCredentials
}
func (m *UserModel) Exists(id int64) (bool, error) { func (m *UserModel) Exists(id int64) (bool, error) {
switch id { switch id {
case 1: case 1:

View File

@ -35,13 +35,23 @@ type UserModel struct {
Settings map[string]Setting 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 { type UserModelInterface interface {
InitializeSettingsMap() error InitializeSettingsMap() error
Insert(shortId uint64, username string, email string, password string, settings UserSettings) 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) Get(shortId uint64) (User, error)
GetById(id int64) (User, error) GetById(id int64) (User, error)
GetAll() ([]User, error) GetAll() ([]User, error)
Authenticate(email, password string) (int64, error) Authenticate(email, password string) (int64, error)
AuthenticateByOIDC(email, subject string) (int64, error)
Exists(id int64) (bool, error) Exists(id int64) (bool, error)
UpdateUserSettings(userId int64, settings UserSettings) error UpdateUserSettings(userId int64, settings UserSettings) error
UpdateSetting(userId int64, setting Setting, value string) error UpdateSetting(userId int64, setting Setting, value string) error
@ -126,6 +136,49 @@ func (m *UserModel) Insert(shortId uint64, username string, email string, passwo
return nil 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) { func (m *UserModel) Get(shortId uint64) (User, error) {
stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE ShortId = ? AND Deleted IS NULL` stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE ShortId = ? AND Deleted IS NULL`
tx, err := m.DB.Begin() tx, err := m.DB.Begin()
@ -239,6 +292,51 @@ func (m *UserModel) Authenticate(email, password string) (int64, error) {
return id, nil return id, nil
} }
func (m *UserModel) AuthenticateByOIDC(email string, subject string) (int64, error) {
var id int64
var s sql.NullString
tx, err := m.DB.Begin()
if err != nil {
return -1, err
}
stmt := `SELECT Id, OIDCSubject FROM users WHERE Email = ?`
err = tx.QueryRow(stmt, email, subject).Scan(&id, &s)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
return -1, err
}
return -1, ErrNoRecord
} else {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
return -1, err
}
return -1, err
}
}
if !s.Valid {
stmt = `UPDATE users SET OIDCSubject = ? WHERE Id = ?`
_, err = tx.Exec(stmt, subject, id)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
return -1, err
}
return -1, err
}
} else if subject != s.String {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
return -1, ErrInvalidCredentials
}
}
err = tx.Commit()
if err != nil {
return -1, err
}
return id, nil
}
func (m *UserModel) Exists(id int64) (bool, error) { func (m *UserModel) Exists(id int64) (bool, error) {
var exists bool var exists bool
stmt := `SELECT EXISTS(SELECT true FROM users WHERE Id = ? AND DELETED IS NULL)` 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

@ -12,6 +12,7 @@ type CommonData struct {
CSRFToken string CSRFToken string
CurrentUser *models.User CurrentUser *models.User
IsHtmx bool IsHtmx bool
RootUrl string
} }
func shortIdToSlug(shortId uint64) string { func shortIdToSlug(shortId uint64) string {

View File

@ -102,7 +102,7 @@ func topNav(data CommonData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentUser.Username) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentUser.Username)
if templ_7745c5c3_Err != nil { 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: 45, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -121,7 +121,7 @@ func topNav(data CommonData) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(hxHeaders) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(hxHeaders)
if templ_7745c5c3_Err != nil { 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: 53, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -202,7 +202,7 @@ func base(title string, data CommonData) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil { 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: 72, Col: 17}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -232,7 +232,7 @@ func base(title string, data CommonData) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Flash) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Flash)
if templ_7745c5c3_Err != nil { 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: 84, Col: 36}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {

View File

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

View File

@ -143,7 +143,7 @@ func UserLogin(title string, data CommonData, form forms.UserLoginForm) templ.Co
return templ_7745c5c3_Err 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\"> <a href=\"/users/login/oidc\">Login with SSO</a></div></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -197,7 +197,7 @@ func UserRegistration(title string, data CommonData, form forms.UserRegistration
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken)
if templ_7745c5c3_Err != nil { 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: 44, Col: 64}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -220,7 +220,7 @@ func UserRegistration(title string, data CommonData, form forms.UserRegistration
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(error) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(error)
if templ_7745c5c3_Err != nil { 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: 49, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -238,7 +238,7 @@ func UserRegistration(title string, data CommonData, form forms.UserRegistration
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
if templ_7745c5c3_Err != nil { 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: 51, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -261,7 +261,7 @@ func UserRegistration(title string, data CommonData, form forms.UserRegistration
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(error) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(error)
if templ_7745c5c3_Err != nil { 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: 57, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -279,7 +279,7 @@ func UserRegistration(title string, data CommonData, form forms.UserRegistration
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(form.Email) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(form.Email)
if templ_7745c5c3_Err != nil { 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: 59, Col: 65}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -302,7 +302,7 @@ func UserRegistration(title string, data CommonData, form forms.UserRegistration
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(error) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(error)
if templ_7745c5c3_Err != nil { 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: 65, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -367,7 +367,7 @@ func UserProfile(title string, data CommonData, user models.User) templ.Componen
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil { 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: 78, Col: 21}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -380,7 +380,7 @@ func UserProfile(title string, data CommonData, user models.User) templ.Componen
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil { 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: 79, Col: 17}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -441,7 +441,7 @@ func UserSettingsView(data CommonData, timezones []string) templ.Component {
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken)
if templ_7745c5c3_Err != nil { 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: 89, Col: 65}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -460,7 +460,7 @@ func UserSettingsView(data CommonData, timezones []string) templ.Component {
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(tz) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
if templ_7745c5c3_Err != nil { 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: 94, Col: 25}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -473,7 +473,7 @@ func UserSettingsView(data CommonData, timezones []string) templ.Component {
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(tz) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
if templ_7745c5c3_Err != nil { 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: 94, Col: 48}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -491,7 +491,7 @@ func UserSettingsView(data CommonData, timezones []string) templ.Component {
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tz) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
if templ_7745c5c3_Err != nil { 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: 96, Col: 25}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -504,7 +504,7 @@ func UserSettingsView(data CommonData, timezones []string) templ.Component {
var templ_7745c5c3_Var26 string var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tz) templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
if templ_7745c5c3_Err != nil { 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: 96, Col: 32}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {