A load more changes

This commit is contained in:
Melon 2024-09-13 15:31:40 +01:00
parent 51e33322d3
commit 7064afd55e
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
24 changed files with 245 additions and 272 deletions

11
auth/auth.go Normal file
View File

@ -0,0 +1,11 @@
package auth
import "github.com/1f349/lavender/database"
type LoginProvider interface {
AttemptLogin(username, password string) (database.User, error)
}
type OAuthProvider interface {
AttemptLogin(username string) (database.User, error)
}

1
auth/login.go Normal file
View File

@ -0,0 +1 @@
package auth

1
auth/oauth.go Normal file
View File

@ -0,0 +1 @@
package auth

View File

@ -1,4 +1,4 @@
package server package auth
type UserInfoFields map[string]any type UserInfoFields map[string]any

View File

@ -13,6 +13,7 @@ import (
"github.com/cloudflare/tableflip" "github.com/cloudflare/tableflip"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
"github.com/google/subcommands" "github.com/google/subcommands"
"github.com/julienschmidt/httprouter"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/spf13/afero" "github.com/spf13/afero"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -122,7 +123,8 @@ func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{})
logger.Logger.Fatal("Listen failed", "err", err) logger.Logger.Fatal("Listen failed", "err", err)
} }
mux := server.NewHttpServer(config, db, signingKey) mux := httprouter.New()
server.SetupRouter(mux, config, db, signingKey)
srv := &http.Server{ srv := &http.Server{
Handler: mux, Handler: mux,
ReadTimeout: time.Minute, ReadTimeout: time.Minute,

View File

@ -13,5 +13,5 @@ type Conf struct {
Kid string `yaml:"kid"` Kid string `yaml:"kid"`
Namespace string `yaml:"namespace"` Namespace string `yaml:"namespace"`
Mail mail.Mail `yaml:"mail"` Mail mail.Mail `yaml:"mail"`
SsoServices map[string]issuer.SsoConfig `yaml:"ssoServices"` SsoServices []issuer.SsoConfig `yaml:"ssoServices"`
} }

View File

@ -4,29 +4,30 @@ CREATE TABLE users
subject TEXT NOT NULL UNIQUE, subject TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, password TEXT NOT NULL,
change_password BOOLEAN NOT NULL,
email TEXT NOT NULL, email TEXT NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT 0, email_verified BOOLEAN NOT NULL,
updated_at DATETIME NOT NULL, updated_at DATETIME NOT NULL,
registered DATETIME NOT NULL, registered DATETIME NOT NULL,
active BOOLEAN NOT NULL DEFAULT 1 active BOOLEAN NOT NULL DEFAULT 1,
);
CREATE INDEX users_subject ON users (subject);
CREATE TABLE profiles
(
subject TEXT NOT NULL UNIQUE PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
picture TEXT NOT NULL DEFAULT '', picture TEXT NOT NULL DEFAULT '',
website TEXT NOT NULL DEFAULT '', website TEXT NOT NULL DEFAULT '',
pronouns TEXT NOT NULL DEFAULT 'they/them', pronouns TEXT NOT NULL DEFAULT 'they/them',
birthdate DATE NULL, birthdate DATE NULL DEFAULT NULL,
zone TEXT NOT NULL DEFAULT 'UTC', zone TEXT NOT NULL DEFAULT 'UTC',
locale TEXT NOT NULL DEFAULT 'en-US', locale TEXT NOT NULL DEFAULT 'en-US',
updated_at DATETIME NOT NULL
auth_type INTEGER NOT NULL,
auth_namespace TEXT NOT NULL,
auth_user TEXT NOT NULL
); );
CREATE INDEX users_subject ON users (subject);
CREATE TABLE roles CREATE TABLE roles
( (
id INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT,

View File

@ -2,7 +2,6 @@ package database
import ( import (
"context" "context"
"github.com/1f349/lavender/database/types"
"github.com/1f349/lavender/password" "github.com/1f349/lavender/password"
"github.com/google/uuid" "github.com/google/uuid"
"time" "time"
@ -10,10 +9,10 @@ import (
type AddUserParams struct { type AddUserParams struct {
Name string `json:"name"` Name string `json:"name"`
Username string `json:"username"` Subject string `json:"subject"`
Password string `json:"password"` Password string `json:"password"`
Email string `json:"email"` Email string `json:"email"`
Role types.UserRole `json:"role"` EmailVerified bool `json:"email_verified"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Active bool `json:"active"` Active bool `json:"active"`
} }
@ -28,7 +27,7 @@ func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) (string, error
Subject: uuid.NewString(), Subject: uuid.NewString(),
Password: pwHash, Password: pwHash,
Email: arg.Email, Email: arg.Email,
EmailVerified: false, EmailVerified: arg.EmailVerified,
UpdatedAt: n, UpdatedAt: n,
Registered: n, Registered: n,
Active: true, Active: true,

View File

@ -6,6 +6,10 @@ FROM users;
INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active) INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active)
VALUES (?, ?, ?, ?, ?, ?, ?); VALUES (?, ?, ?, ?, ?, ?, ?);
-- name: addOAuthUser :exec
INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active)
VALUES (?, ?, ?, ?, ?, ?, ?);
-- name: checkLogin :one -- name: checkLogin :one
SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject) == 1 AS has_otp, email, email_verified SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject) == 1 AS has_otp, email, email_verified
FROM users FROM users
@ -25,7 +29,7 @@ FROM users_roles
INNER JOIN users u on u.id = users_roles.user_id INNER JOIN users u on u.id = users_roles.user_id
WHERE u.subject = ?; WHERE u.subject = ?;
-- name: UserHasRole :one -- name: UserHasRole :exec
SELECT 1 SELECT 1
FROM roles FROM roles
INNER JOIN users_roles on users_roles.user_id = roles.id INNER JOIN users_roles on users_roles.user_id = roles.id

View File

@ -0,0 +1,16 @@
package types
type AuthType byte
const (
AuthTypeBase AuthType = iota
AuthTypeOauth2
)
var authTypeNames = map[AuthType]string{
AuthTypeOauth2: "OAuth2",
}
func (t AuthType) String() string {
return authTypeNames[t]
}

View File

@ -78,7 +78,7 @@ func (q *Queries) HasUser(ctx context.Context) (bool, error) {
return hasuser, err return hasuser, err
} }
const userHasRole = `-- name: UserHasRole :one const userHasRole = `-- name: UserHasRole :exec
SELECT 1 SELECT 1
FROM roles FROM roles
INNER JOIN users_roles on users_roles.user_id = roles.id INNER JOIN users_roles on users_roles.user_id = roles.id
@ -92,11 +92,9 @@ type UserHasRoleParams struct {
Subject string `json:"subject"` Subject string `json:"subject"`
} }
func (q *Queries) UserHasRole(ctx context.Context, arg UserHasRoleParams) (int64, error) { func (q *Queries) UserHasRole(ctx context.Context, arg UserHasRoleParams) error {
row := q.db.QueryRowContext(ctx, userHasRole, arg.Role, arg.Subject) _, err := q.db.ExecContext(ctx, userHasRole, arg.Role, arg.Subject)
var column_1 int64 return err
err := row.Scan(&column_1)
return column_1, err
} }
const addUser = `-- name: addUser :exec const addUser = `-- name: addUser :exec

View File

@ -18,6 +18,7 @@ var httpGet = http.Get
// The path `/.well-known/openid-configuration` should be available // The path `/.well-known/openid-configuration` should be available
type SsoConfig struct { type SsoConfig struct {
Addr utils.JsonUrl `json:"addr"` // https://login.example.com Addr utils.JsonUrl `json:"addr"` // https://login.example.com
Namespace string `json:"namespace"` // example.com
Client SsoConfigClient `json:"client"` Client SsoConfigClient `json:"client"`
} }

5
role/role.go Normal file
View File

@ -0,0 +1,5 @@
package role
const prefix = "lavender:"
const LavenderAdmin = prefix + "admin"

View File

@ -1,8 +1,11 @@
package server package server
import ( import (
"database/sql"
"errors" "errors"
"github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"github.com/1f349/lavender/role"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"net/http" "net/http"
"net/url" "net/url"
@ -13,24 +16,41 @@ type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprout
type UserAuth struct { type UserAuth struct {
Subject string Subject string
DisplayName string NeedOtp bool
UserInfo UserInfoFields UserInfo auth.UserInfoFields
} }
func (u UserAuth) IsGuest() bool { return u.Subject == "" } func (u UserAuth) IsGuest() bool { return u.Subject == "" }
func (u UserAuth) NextFlowUrl(origin *url.URL) *url.URL {
if u.NeedOtp {
return PrepareRedirectUrl("/login/otp", origin)
}
return nil
}
var ErrAuthHttpError = errors.New("auth http error") var ErrAuthHttpError = errors.New("auth http error")
func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle { func (h *httpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle {
return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
var roles []string var hasRole bool
if h.DbTx(rw, func(tx *database.Queries) (err error) { if h.DbTx(rw, func(tx *database.Queries) (err error) {
roles, err = tx.GetUserRoles(req.Context(), auth.Subject) err = tx.UserHasRole(req.Context(), database.UserHasRoleParams{
Role: role.LavenderAdmin,
Subject: auth.Subject,
})
switch {
case err == nil:
hasRole = true
case errors.Is(err, sql.ErrNoRows):
hasRole = false
err = nil
}
return return
}) { }) {
return return
} }
if !HasRole(roles, "lavender:admin") { if !hasRole {
http.Error(rw, "403 Forbidden", http.StatusForbidden) http.Error(rw, "403 Forbidden", http.StatusForbidden)
return return
} }
@ -38,7 +58,7 @@ func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Han
}) })
} }
func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle { func (h *httpServer) RequireAuthentication(next UserHandler) httprouter.Handle {
return h.OptionalAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { return h.OptionalAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
if auth.IsGuest() { if auth.IsGuest() {
redirectUrl := PrepareRedirectUrl("/login", req.URL) redirectUrl := PrepareRedirectUrl("/login", req.URL)
@ -49,7 +69,7 @@ func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle {
}) })
} }
func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle { func (h *httpServer) OptionalAuthentication(next UserHandler) httprouter.Handle {
return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
authUser, err := h.internalAuthenticationHandler(rw, req) authUser, err := h.internalAuthenticationHandler(rw, req)
if err != nil { if err != nil {
@ -62,7 +82,7 @@ func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle
} }
} }
func (h *HttpServer) internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (UserAuth, error) { func (h *httpServer) internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (UserAuth, error) {
// Delete previous login data cookie // Delete previous login data cookie
http.SetCookie(rw, &http.Cookie{ http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-data", Name: "lavender-login-data",

View File

@ -12,7 +12,7 @@ var ErrDatabaseActionFailed = errors.New("database action failed")
// DbTx wraps a database transaction with http error messages and a simple action // DbTx wraps a database transaction with http error messages and a simple action
// function. If the action function returns an error the transaction will be // function. If the action function returns an error the transaction will be
// rolled back. If there is no error then the transaction is committed. // rolled back. If there is no error then the transaction is committed.
func (h *HttpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Queries) error) bool { func (h *httpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Queries) error) bool {
logger.Logger.Helper() logger.Logger.Helper()
if h.DbTxError(action) != nil { if h.DbTxError(action) != nil {
http.Error(rw, "Database error", http.StatusInternalServerError) http.Error(rw, "Database error", http.StatusInternalServerError)
@ -22,7 +22,7 @@ func (h *HttpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Queri
return false return false
} }
func (h *HttpServer) DbTxError(action func(tx *database.Queries) error) error { func (h *httpServer) DbTxError(action func(tx *database.Queries) error) error {
logger.Logger.Helper() logger.Logger.Helper()
err := action(h.db) err := action(h.db)
if err != nil { if err != nil {

View File

@ -3,13 +3,14 @@ package server
import ( import (
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"github.com/1f349/lavender/pages" "github.com/1f349/lavender/pages"
"github.com/1f349/lavender/role"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"net/http" "net/http"
"time" "time"
) )
func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { func (h *httpServer) Home(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
rw.Header().Set("Content-Type", "text/html") rw.Header().Set("Content-Type", "text/html")
lNonce := uuid.NewString() lNonce := uuid.NewString()
http.SetCookie(rw, &http.Cookie{ http.SetCookie(rw, &http.Cookie{
@ -30,7 +31,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute
var isAdmin bool var isAdmin bool
h.DbTx(rw, func(tx *database.Queries) (err error) { h.DbTx(rw, func(tx *database.Queries) (err error) {
_, err = tx.UserHasRole(req.Context(), database.UserHasRoleParams{Role: "lavender:admin", Subject: auth.Subject}) err = tx.UserHasRole(req.Context(), database.UserHasRoleParams{Role: role.LavenderAdmin, Subject: auth.Subject})
isAdmin = err == nil isAdmin = err == nil
return nil return nil
}) })

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"github.com/1f349/lavender/database"
"github.com/1f349/mjwt" "github.com/1f349/mjwt"
"github.com/1f349/mjwt/auth" "github.com/1f349/mjwt/auth"
"github.com/go-oauth2/oauth2/v4" "github.com/go-oauth2/oauth2/v4"
@ -15,15 +14,19 @@ import (
type JWTAccessGenerate struct { type JWTAccessGenerate struct {
signer *mjwt.Issuer signer *mjwt.Issuer
db *database.Queries db mjwtGetUserRoles
} }
func NewJWTAccessGenerate(signer *mjwt.Issuer, db *database.Queries) *JWTAccessGenerate { func NewMJWTAccessGenerate(signer *mjwt.Issuer, db mjwtGetUserRoles) *JWTAccessGenerate {
return &JWTAccessGenerate{signer, db} return &JWTAccessGenerate{signer, db}
} }
var _ oauth2.AccessGenerate = &JWTAccessGenerate{} var _ oauth2.AccessGenerate = &JWTAccessGenerate{}
type mjwtGetUserRoles interface {
GetUserRoles(ctx context.Context, subject string) ([]string, error)
}
func (j *JWTAccessGenerate) Token(ctx context.Context, data *oauth2.GenerateBasic, isGenRefresh bool) (access, refresh string, err error) { func (j *JWTAccessGenerate) Token(ctx context.Context, data *oauth2.GenerateBasic, isGenRefresh bool) (access, refresh string, err error) {
roles, err := j.db.GetUserRoles(ctx, data.UserID) roles, err := j.db.GetUserRoles(ctx, data.UserID)
if err != nil { if err != nil {

View File

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
auth2 "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"github.com/1f349/lavender/issuer" "github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/pages" "github.com/1f349/lavender/pages"
@ -21,7 +22,7 @@ import (
"time" "time"
) )
func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
if !auth.IsGuest() { if !auth.IsGuest() {
h.SafeRedirect(rw, req) h.SafeRedirect(rw, req)
return return
@ -42,7 +43,7 @@ func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr
}) })
} }
func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
if !auth.IsGuest() { if !auth.IsGuest() {
h.SafeRedirect(rw, req) h.SafeRedirect(rw, req)
return return
@ -95,7 +96,7 @@ func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
http.Redirect(rw, req, nextUrl, http.StatusFound) http.Redirect(rw, req, nextUrl, http.StatusFound)
} }
func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth UserAuth) { func (h *httpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth UserAuth) {
flowState, ok := h.flowState.Get(req.FormValue("state")) flowState, ok := h.flowState.Get(req.FormValue("state"))
if !ok { if !ok {
http.Error(rw, "Invalid flow state", http.StatusBadRequest) http.Error(rw, "Invalid flow state", http.StatusBadRequest)
@ -123,7 +124,7 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _
h.SafeRedirect(rw, req) h.SafeRedirect(rw, req)
} }
func (h *HttpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) { func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) {
sessionData, err := h.fetchUserInfo(sso, token) sessionData, err := h.fetchUserInfo(sso, token)
if err != nil || sessionData.Subject == "" { if err != nil || sessionData.Subject == "" {
return UserAuth{}, fmt.Errorf("failed to fetch user info") return UserAuth{}, fmt.Errorf("failed to fetch user info")
@ -138,6 +139,16 @@ func (h *HttpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost") uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost")
uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified") uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified")
id, err := tx.AddUser(req.Context(), database.AddUserParams{
Name: "",
Subject: sessionData.Subject,
Password: "",
Email: uEmail,
EmailVerified: uEmailVerified,
UpdatedAt: time.Now(),
Active: true,
})
return err
return tx.AddUser(req.Context(), database.AddUserParams{ return tx.AddUser(req.Context(), database.AddUserParams{
Subject: sessionData.Subject, Subject: sessionData.Subject,
Email: uEmail, Email: uEmail,
@ -180,7 +191,7 @@ const twelveHours = 12 * time.Hour
const oneWeek = 7 * 24 * time.Hour const oneWeek = 7 * 24 * time.Hour
type lavenderLoginAccess struct { type lavenderLoginAccess struct {
UserInfo UserInfoFields `json:"user_info"` UserInfo auth2.UserInfoFields `json:"user_info"`
auth.AccessTokenClaims auth.AccessTokenClaims
} }
@ -197,7 +208,7 @@ func (l lavenderLoginRefresh) Valid() error { return l.RefreshTokenClaims.Valid(
func (l lavenderLoginRefresh) Type() string { return "lavender-login-refresh" } func (l lavenderLoginRefresh) Type() string { return "lavender-login-refresh" }
func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAuth, loginName string) bool { func (h *httpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAuth, loginName string) bool {
ps := auth.NewPermStorage() ps := auth.NewPermStorage()
accId := uuid.NewString() accId := uuid.NewString()
gen, err := h.signingKey.GenerateJwt(authData.Subject, accId, jwt.ClaimStrings{h.conf.BaseUrl}, twelveHours, lavenderLoginAccess{ gen, err := h.signingKey.GenerateJwt(authData.Subject, accId, jwt.ClaimStrings{h.conf.BaseUrl}, twelveHours, lavenderLoginAccess{
@ -248,7 +259,7 @@ func readJwtCookie[T mjwt.Claims](req *http.Request, cookieName string, signingK
return b, nil return b, nil
} }
func (h *HttpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Request, u *UserAuth) error { func (h *httpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Request, u *UserAuth) error {
loginData, err := readJwtCookie[lavenderLoginAccess](req, "lavender-login-access", h.signingKey.KeyStore()) loginData, err := readJwtCookie[lavenderLoginAccess](req, "lavender-login-access", h.signingKey.KeyStore())
if err != nil { if err != nil {
return h.readLoginRefreshCookie(rw, req, u) return h.readLoginRefreshCookie(rw, req, u)
@ -260,7 +271,7 @@ func (h *HttpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Req
return nil return nil
} }
func (h *HttpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Request, userAuth *UserAuth) error { func (h *httpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Request, userAuth *UserAuth) error {
refreshData, err := readJwtCookie[lavenderLoginRefresh](req, "lavender-login-refresh", h.signingKey.KeyStore()) refreshData, err := readJwtCookie[lavenderLoginRefresh](req, "lavender-login-refresh", h.signingKey.KeyStore())
if err != nil { if err != nil {
return err return err
@ -298,14 +309,14 @@ func (h *HttpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Re
return nil return nil
} }
func (h *HttpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) { func (h *httpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) {
res, err := sso.OAuth2Config.Client(context.Background(), token).Get(sso.UserInfoEndpoint) res, err := sso.OAuth2Config.Client(context.Background(), token).Get(sso.UserInfoEndpoint)
if err != nil || res.StatusCode != http.StatusOK { if err != nil || res.StatusCode != http.StatusOK {
return UserAuth{}, fmt.Errorf("request failed") return UserAuth{}, fmt.Errorf("request failed")
} }
defer res.Body.Close() defer res.Body.Close()
var userInfoJson UserInfoFields var userInfoJson auth2.UserInfoFields
if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil { if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil {
return UserAuth{}, err return UserAuth{}, err
} }

View File

@ -4,6 +4,7 @@ import (
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"github.com/1f349/lavender/pages" "github.com/1f349/lavender/pages"
"github.com/1f349/lavender/password" "github.com/1f349/lavender/password"
"github.com/1f349/lavender/role"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"net/http" "net/http"
@ -11,7 +12,7 @@ import (
"strconv" "strconv"
) )
func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { func (h *httpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
q := req.URL.Query() q := req.URL.Query()
offset, _ := strconv.Atoi(q.Get("offset")) offset, _ := strconv.Atoi(q.Get("offset"))
@ -24,7 +25,7 @@ func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _
} }
appList, err = tx.GetAppList(req.Context(), database.GetAppListParams{ appList, err = tx.GetAppList(req.Context(), database.GetAppListParams{
Owner: auth.Subject, Owner: auth.Subject,
Column2: HasRole(roles, "lavender:admin"), Column2: HasRole(roles, role.LavenderAdmin),
Offset: int64(offset), Offset: int64(offset),
}) })
return return
@ -59,7 +60,7 @@ func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _
pages.RenderPageTemplate(rw, "manage-apps", m) pages.RenderPageTemplate(rw, "manage-apps", m)
} }
func (h *HttpServer) ManageAppsCreateGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { func (h *httpServer) ManageAppsCreateGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
var roles string var roles string
if h.DbTx(rw, func(tx *database.Queries) (err error) { if h.DbTx(rw, func(tx *database.Queries) (err error) {
roles, err = tx.GetUserRoles(req.Context(), auth.Subject) roles, err = tx.GetUserRoles(req.Context(), auth.Subject)
@ -70,7 +71,7 @@ func (h *HttpServer) ManageAppsCreateGet(rw http.ResponseWriter, req *http.Reque
m := map[string]any{ m := map[string]any{
"ServiceName": h.conf.ServiceName, "ServiceName": h.conf.ServiceName,
"IsAdmin": HasRole(roles, "lavender:admin"), "IsAdmin": HasRole(roles, role.LavenderAdmin),
} }
rw.Header().Set("Content-Type", "text/html") rw.Header().Set("Content-Type", "text/html")
@ -78,7 +79,7 @@ func (h *HttpServer) ManageAppsCreateGet(rw http.ResponseWriter, req *http.Reque
pages.RenderPageTemplate(rw, "manage-apps-create", m) pages.RenderPageTemplate(rw, "manage-apps-create", m)
} }
func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { func (h *httpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
err := req.ParseForm() err := req.ParseForm()
if err != nil { if err != nil {
http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest) http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest)

View File

@ -3,13 +3,14 @@ package server
import ( import (
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"github.com/1f349/lavender/pages" "github.com/1f349/lavender/pages"
"github.com/1f349/lavender/role"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
) )
func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { func (h *httpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
q := req.URL.Query() q := req.URL.Query()
offset, _ := strconv.Atoi(q.Get("offset")) offset, _ := strconv.Atoi(q.Get("offset"))
@ -25,7 +26,7 @@ func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _
}) { }) {
return return
} }
if !HasRole(roles, "lavender:admin") { if !HasRole(roles, role.LavenderAdmin) {
http.Error(rw, "403 Forbidden", http.StatusForbidden) http.Error(rw, "403 Forbidden", http.StatusForbidden)
return return
} }
@ -56,7 +57,7 @@ func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _
pages.RenderPageTemplate(rw, "manage-users", m) pages.RenderPageTemplate(rw, "manage-users", m)
} }
func (h *HttpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { func (h *httpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
err := req.ParseForm() err := req.ParseForm()
if err != nil { if err != nil {
http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest) http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest)

View File

@ -10,7 +10,7 @@ import (
"strings" "strings"
) )
func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { func (h *httpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
// function is only called with GET or POST method // function is only called with GET or POST method
isPost := req.Method == http.MethodPost isPost := req.Method == http.MethodPost
@ -128,7 +128,7 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request
http.Redirect(rw, req, parsedRedirect.String(), http.StatusFound) http.Redirect(rw, req, parsedRedirect.String(), http.StatusFound)
} }
func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Request) (string, error) { func (h *httpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Request) (string, error) {
err := req.ParseForm() err := req.ParseForm()
if err != nil { if err != nil {
return "", err return "", err

38
server/openid.go Normal file
View File

@ -0,0 +1,38 @@
package server
import (
"bytes"
"encoding/json"
"github.com/1f349/lavender/logger"
"github.com/1f349/lavender/openid"
"github.com/1f349/mjwt"
"github.com/julienschmidt/httprouter"
"net/http"
)
func SetupOpenId(r *httprouter.Router, baseUrl string, signingKey *mjwt.Issuer) {
openIdConf := openid.GenConfig(baseUrl, []string{
"openid", "name", "username", "profile", "email", "birthdate", "age", "zoneinfo", "locale",
}, []string{
"sub", "name", "preferred_username", "profile", "picture", "website", "email", "email_verified", "gender", "birthdate", "zoneinfo", "locale", "updated_at",
})
openIdBytes, err := json.Marshal(openIdConf)
if err != nil {
logger.Logger.Fatal("Failed to generate OpenID configuration", "err", err)
}
jwkSetBuffer := new(bytes.Buffer)
err = mjwt.WriteJwkSetJson(jwkSetBuffer, []*mjwt.Issuer{signingKey})
if err != nil {
logger.Logger.Fatal("Failed to generate JWK Set", "err", err)
}
r.GET("/.well-known/openid-configuration", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(openIdBytes)
})
r.GET("/.well-known/jwks.json", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(jwkSetBuffer.Bytes())
})
}

View File

@ -6,7 +6,7 @@ import (
) )
func TestHasRole(t *testing.T) { func TestHasRole(t *testing.T) {
assert.True(t, HasRole("lavender:admin test:something-else", "lavender:admin")) assert.True(t, HasRole([]string{"lavender:admin", "test:something-else"}, "lavender:admin"))
assert.False(t, HasRole("lavender:admin,test:something-else", "lavender:admin")) assert.False(t, HasRole([]string{"lavender:admin", "test:something-else"}, "lavender:admin"))
assert.False(t, HasRole("lavender: test:something-else", "lavender:admin")) assert.False(t, HasRole([]string{"lavender:", "test:something-else"}, "lavender:admin"))
} }

View File

@ -1,20 +1,16 @@
package server package server
import ( import (
"bytes" "errors"
"crypto/subtle"
"encoding/json"
"github.com/1f349/cache" "github.com/1f349/cache"
clientStore "github.com/1f349/lavender/client-store" clientStore "github.com/1f349/lavender/client-store"
"github.com/1f349/lavender/conf" "github.com/1f349/lavender/conf"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"github.com/1f349/lavender/issuer" "github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/logger"
"github.com/1f349/lavender/openid"
"github.com/1f349/lavender/pages" "github.com/1f349/lavender/pages"
scope2 "github.com/1f349/lavender/scope" scope2 "github.com/1f349/lavender/scope"
"github.com/1f349/mjwt" "github.com/1f349/mjwt"
"github.com/go-oauth2/oauth2/v4/errors" "github.com/go-oauth2/oauth2/v4/generates"
"github.com/go-oauth2/oauth2/v4/manage" "github.com/go-oauth2/oauth2/v4/manage"
"github.com/go-oauth2/oauth2/v4/server" "github.com/go-oauth2/oauth2/v4/server"
"github.com/go-oauth2/oauth2/v4/store" "github.com/go-oauth2/oauth2/v4/store"
@ -28,7 +24,7 @@ import (
var errInvalidScope = errors.New("missing required scope") var errInvalidScope = errors.New("missing required scope")
type HttpServer struct { type httpServer struct {
r *httprouter.Router r *httprouter.Router
oauthSrv *server.Server oauthSrv *server.Server
oauthMgr *manage.Manager oauthMgr *manage.Manager
@ -36,7 +32,12 @@ type HttpServer struct {
conf conf.Conf conf conf.Conf
signingKey *mjwt.Issuer signingKey *mjwt.Issuer
manager *issuer.Manager manager *issuer.Manager
// flowState contains the
flowState *cache.Cache[string, flowStateData] flowState *cache.Cache[string, flowStateData]
// mailLinkCache contains a mapping of verify uuids to user uuids
mailLinkCache *cache.Cache[mailLinkKey, string]
} }
type flowStateData struct { type flowStateData struct {
@ -45,52 +46,44 @@ type flowStateData struct {
redirect string redirect string
} }
func NewHttpServer(config conf.Conf, db *database.Queries, signingKey *mjwt.Issuer) *httprouter.Router { type mailLink byte
r := httprouter.New()
const (
mailLinkDelete mailLink = iota
mailLinkResetPassword
mailLinkVerifyEmail
)
type mailLinkKey struct {
action mailLink
data string
}
func SetupRouter(r *httprouter.Router, config conf.Conf, db *database.Queries, signingKey *mjwt.Issuer) {
// remove last slash from baseUrl
config.BaseUrl = strings.TrimRight(config.BaseUrl, "/")
contentCache := time.Now() contentCache := time.Now()
// remove last slash from baseUrl hs := &httpServer{
{ r: r,
l := len(config.BaseUrl)
if config.BaseUrl[l-1] == '/' {
config.BaseUrl = config.BaseUrl[:l-1]
}
}
openIdConf := openid.GenConfig(config.BaseUrl, []string{"openid", "name", "username", "profile", "email", "birthdate", "age", "zoneinfo", "locale"}, []string{"sub", "name", "preferred_username", "profile", "picture", "website", "email", "email_verified", "gender", "birthdate", "zoneinfo", "locale", "updated_at"})
openIdBytes, err := json.Marshal(openIdConf)
if err != nil {
logger.Logger.Fatal("Failed to generate OpenID configuration", "err", err)
}
jwkSetBuffer := new(bytes.Buffer)
err = mjwt.WriteJwkSetJson(jwkSetBuffer, []*mjwt.Issuer{signingKey})
if err != nil {
logger.Logger.Fatal("Failed to generate JWK Set", "err", err)
}
oauthManager := manage.NewDefaultManager()
oauthSrv := server.NewServer(server.NewConfig(), oauthManager)
hs := &HttpServer{
r: httprouter.New(),
oauthSrv: oauthSrv,
oauthMgr: oauthManager,
db: db, db: db,
conf: config, conf: config,
signingKey: signingKey, signingKey: signingKey,
flowState: cache.New[string, flowStateData](), flowState: cache.New[string, flowStateData](),
mailLinkCache: cache.New[mailLinkKey, string](),
} }
hs.manager, err = issuer.NewManager(config.SsoServices) oauthManager := manage.NewManager()
if err != nil { oauthManager.MapAuthorizeGenerate(generates.NewAuthorizeGenerate())
logger.Logger.Fatal("Failed to reload SSO service manager", "err", err)
}
oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
oauthManager.MustTokenStorage(store.NewMemoryTokenStore()) oauthManager.MustTokenStorage(store.NewMemoryTokenStore())
oauthManager.MapAccessGenerate(NewJWTAccessGenerate(hs.signingKey, db)) oauthManager.MapAccessGenerate(NewMJWTAccessGenerate(signingKey, db))
oauthManager.MapClientStorage(clientStore.New(db)) oauthManager.MapClientStorage(clientStore.New(db))
oauthSrv := server.NewDefaultServer(oauthManager)
oauthSrv.SetClientInfoHandler(func(req *http.Request) (clientID, clientSecret string, err error) { oauthSrv.SetClientInfoHandler(func(req *http.Request) (clientID, clientSecret string, err error) {
cId, cSecret, err := server.ClientBasicHandler(req) cId, cSecret, err := server.ClientBasicHandler(req)
if cId == "" && cSecret == "" { if cId == "" && cSecret == "" {
@ -117,47 +110,10 @@ func NewHttpServer(config conf.Conf, db *database.Queries, signingKey *mjwt.Issu
}) })
addIdTokenSupport(oauthSrv, db, signingKey) addIdTokenSupport(oauthSrv, db, signingKey)
r.GET("/.well-known/openid-configuration", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { ssoManager := issuer.NewManager(config.SsoServices)
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(openIdBytes)
})
r.GET("/.well-known/jwks.json", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(jwkSetBuffer.Bytes())
})
r.GET("/", hs.OptionalAuthentication(hs.Home))
// login SetupOpenId(r, config.BaseUrl, signingKey)
r.GET("/login", hs.OptionalAuthentication(hs.loginGet)) r.POST("/logout", hs.RequireAuthentication(fu))
r.POST("/login", hs.OptionalAuthentication(hs.loginPost))
r.GET("/callback", hs.OptionalAuthentication(hs.loginCallback))
r.POST("/logout", hs.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
cookie, err := req.Cookie("lavender-nonce")
if err != nil {
http.Error(rw, "Missing nonce", http.StatusBadRequest)
return
}
if subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(req.PostFormValue("nonce"))) == 1 {
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-access",
Path: "/",
MaxAge: -1,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-refresh",
Path: "/",
MaxAge: -1,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(rw, req, "/", http.StatusFound)
return
}
http.Error(rw, "Logout failed", http.StatusInternalServerError)
}))
// theme styles // theme styles
r.GET("/assets/*filepath", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { r.GET("/assets/*filepath", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
@ -170,108 +126,11 @@ func NewHttpServer(config conf.Conf, db *database.Queries, signingKey *mjwt.Issu
http.ServeContent(rw, req, path.Base(name), contentCache, out) http.ServeContent(rw, req, path.Base(name), contentCache, out)
}) })
// management pages SetupManageApps(r)
r.GET("/manage/apps", hs.RequireAuthentication(hs.ManageAppsGet)) SetupManageUsers(r)
r.GET("/manage/apps/create", hs.RequireAuthentication(hs.ManageAppsCreateGet))
r.POST("/manage/apps", hs.RequireAuthentication(hs.ManageAppsPost))
r.GET("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersGet))
r.POST("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersPost))
// oauth pages
r.GET("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint))
r.POST("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint))
r.POST("/token", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
if err := oauthSrv.HandleTokenRequest(rw, req); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
}
})
userInfoRequest := func(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
rw.Header().Set("Access-Control-Allow-Credentials", "true")
rw.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type")
rw.Header().Set("Access-Control-Allow-Origin", strings.TrimSuffix(req.Referer(), "/"))
rw.Header().Set("Access-Control-Allow-Methods", "GET")
if req.Method == http.MethodOptions {
return
} }
token, err := oauthSrv.ValidationBearerToken(req) func (h *httpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) {
if err != nil {
http.Error(rw, "403 Forbidden", http.StatusForbidden)
return
}
userId := token.GetUserID()
sso := hs.manager.FindServiceFromLogin(userId)
if sso == nil {
http.Error(rw, "Invalid user", http.StatusBadRequest)
return
}
var user database.User
if hs.DbTx(rw, func(tx *database.Queries) (err error) {
user, err = tx.GetUser(req.Context(), userId)
return
}) {
return
}
var userInfo UserInfoFields
err = json.Unmarshal([]byte(user.Userinfo), &userInfo)
if err != nil {
http.Error(rw, "500 Internal Server Error", http.StatusInternalServerError)
return
}
claims := ParseClaims(token.GetScope())
if !claims["openid"] {
http.Error(rw, "Invalid scope", http.StatusBadRequest)
return
}
m := make(map[string]any)
if claims["name"] {
m["name"] = userInfo["name"]
}
if claims["username"] {
m["preferred_username"] = userInfo["preferred_username"]
m["login"] = userInfo["login"]
}
if claims["profile"] {
m["profile"] = userInfo["profile"]
m["picture"] = userInfo["picture"]
m["website"] = userInfo["website"]
}
if claims["email"] {
m["email"] = userInfo["email"]
m["email_verified"] = userInfo["email_verified"]
}
if claims["birthdate"] {
m["birthdate"] = userInfo["birthdate"]
}
if claims["age"] {
m["age"] = userInfo["age"]
}
if claims["zoneinfo"] {
m["zoneinfo"] = userInfo["zoneinfo"]
}
if claims["locale"] {
m["locale"] = userInfo["locale"]
}
m["sub"] = userId
m["aud"] = token.GetClientID()
m["updated_at"] = time.Now().Unix()
_ = json.NewEncoder(rw).Encode(m)
}
r.GET("/userinfo", userInfoRequest)
r.OPTIONS("/userinfo", userInfoRequest)
return r
}
func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) {
redirectUrl := req.FormValue("redirect") redirectUrl := req.FormValue("redirect")
if redirectUrl == "" { if redirectUrl == "" {
http.Redirect(rw, req, "/", http.StatusFound) http.Redirect(rw, req, "/", http.StatusFound)