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

View File

@ -13,6 +13,7 @@ import (
"github.com/cloudflare/tableflip"
"github.com/golang-jwt/jwt/v4"
"github.com/google/subcommands"
"github.com/julienschmidt/httprouter"
_ "github.com/mattn/go-sqlite3"
"github.com/spf13/afero"
"gopkg.in/yaml.v3"
@ -122,7 +123,8 @@ func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{})
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{
Handler: mux,
ReadTimeout: time.Minute,

View File

@ -6,12 +6,12 @@ import (
)
type Conf struct {
Listen string `yaml:"listen"`
BaseUrl string `yaml:"baseUrl"`
ServiceName string `yaml:"serviceName"`
Issuer string `yaml:"issuer"`
Kid string `yaml:"kid"`
Namespace string `yaml:"namespace"`
Mail mail.Mail `yaml:"mail"`
SsoServices map[string]issuer.SsoConfig `yaml:"ssoServices"`
Listen string `yaml:"listen"`
BaseUrl string `yaml:"baseUrl"`
ServiceName string `yaml:"serviceName"`
Issuer string `yaml:"issuer"`
Kid string `yaml:"kid"`
Namespace string `yaml:"namespace"`
Mail mail.Mail `yaml:"mail"`
SsoServices []issuer.SsoConfig `yaml:"ssoServices"`
}

View File

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

View File

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

View File

@ -6,6 +6,10 @@ FROM users;
INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active)
VALUES (?, ?, ?, ?, ?, ?, ?);
-- name: addOAuthUser :exec
INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active)
VALUES (?, ?, ?, ?, ?, ?, ?);
-- name: checkLogin :one
SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject) == 1 AS has_otp, email, email_verified
FROM users
@ -25,7 +29,7 @@ FROM users_roles
INNER JOIN users u on u.id = users_roles.user_id
WHERE u.subject = ?;
-- name: UserHasRole :one
-- name: UserHasRole :exec
SELECT 1
FROM roles
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
}
const userHasRole = `-- name: UserHasRole :one
const userHasRole = `-- name: UserHasRole :exec
SELECT 1
FROM roles
INNER JOIN users_roles on users_roles.user_id = roles.id
@ -92,11 +92,9 @@ type UserHasRoleParams struct {
Subject string `json:"subject"`
}
func (q *Queries) UserHasRole(ctx context.Context, arg UserHasRoleParams) (int64, error) {
row := q.db.QueryRowContext(ctx, userHasRole, arg.Role, arg.Subject)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
func (q *Queries) UserHasRole(ctx context.Context, arg UserHasRoleParams) error {
_, err := q.db.ExecContext(ctx, userHasRole, arg.Role, arg.Subject)
return err
}
const addUser = `-- name: addUser :exec

View File

@ -17,8 +17,9 @@ var httpGet = http.Get
// SsoConfig is the base URL for an OAUTH/OPENID/SSO login service
// The path `/.well-known/openid-configuration` should be available
type SsoConfig struct {
Addr utils.JsonUrl `json:"addr"` // https://login.example.com
Client SsoConfigClient `json:"client"`
Addr utils.JsonUrl `json:"addr"` // https://login.example.com
Namespace string `json:"namespace"` // example.com
Client SsoConfigClient `json:"client"`
}
type SsoConfigClient struct {

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
import (
"database/sql"
"errors"
"github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/role"
"github.com/julienschmidt/httprouter"
"net/http"
"net/url"
@ -12,25 +15,42 @@ import (
type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth)
type UserAuth struct {
Subject string
DisplayName string
UserInfo UserInfoFields
Subject string
NeedOtp bool
UserInfo auth.UserInfoFields
}
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")
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) {
var roles []string
var hasRole bool
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
}
if !HasRole(roles, "lavender:admin") {
if !hasRole {
http.Error(rw, "403 Forbidden", http.StatusForbidden)
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) {
if auth.IsGuest() {
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) {
authUser, err := h.internalAuthenticationHandler(rw, req)
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
http.SetCookie(rw, &http.Cookie{
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
// function. If the action function returns an error the transaction will be
// 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()
if h.DbTxError(action) != nil {
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
}
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()
err := action(h.db)
if err != nil {

View File

@ -3,13 +3,14 @@ package server
import (
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/pages"
"github.com/1f349/lavender/role"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"net/http"
"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")
lNonce := uuid.NewString()
http.SetCookie(rw, &http.Cookie{
@ -30,7 +31,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute
var isAdmin bool
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
return nil
})

View File

@ -4,7 +4,6 @@ import (
"context"
"crypto/sha256"
"encoding/base64"
"github.com/1f349/lavender/database"
"github.com/1f349/mjwt"
"github.com/1f349/mjwt/auth"
"github.com/go-oauth2/oauth2/v4"
@ -15,15 +14,19 @@ import (
type JWTAccessGenerate struct {
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}
}
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) {
roles, err := j.db.GetUserRoles(ctx, data.UserID)
if err != nil {

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
auth2 "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/pages"
@ -21,7 +22,7 @@ import (
"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() {
h.SafeRedirect(rw, req)
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() {
h.SafeRedirect(rw, req)
return
@ -95,7 +96,7 @@ func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
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"))
if !ok {
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)
}
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)
if err != nil || sessionData.Subject == "" {
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) {
uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost")
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{
Subject: sessionData.Subject,
Email: uEmail,
@ -180,7 +191,7 @@ const twelveHours = 12 * time.Hour
const oneWeek = 7 * 24 * time.Hour
type lavenderLoginAccess struct {
UserInfo UserInfoFields `json:"user_info"`
UserInfo auth2.UserInfoFields `json:"user_info"`
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 (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()
accId := uuid.NewString()
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
}
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())
if err != nil {
return h.readLoginRefreshCookie(rw, req, u)
@ -260,7 +271,7 @@ func (h *HttpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Req
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())
if err != nil {
return err
@ -298,14 +309,14 @@ func (h *HttpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Re
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)
if err != nil || res.StatusCode != http.StatusOK {
return UserAuth{}, fmt.Errorf("request failed")
}
defer res.Body.Close()
var userInfoJson UserInfoFields
var userInfoJson auth2.UserInfoFields
if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil {
return UserAuth{}, err
}

View File

@ -4,6 +4,7 @@ import (
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/pages"
"github.com/1f349/lavender/password"
"github.com/1f349/lavender/role"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"net/http"
@ -11,7 +12,7 @@ import (
"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()
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{
Owner: auth.Subject,
Column2: HasRole(roles, "lavender:admin"),
Column2: HasRole(roles, role.LavenderAdmin),
Offset: int64(offset),
})
return
@ -59,7 +60,7 @@ func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _
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
if h.DbTx(rw, func(tx *database.Queries) (err error) {
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{
"ServiceName": h.conf.ServiceName,
"IsAdmin": HasRole(roles, "lavender:admin"),
"IsAdmin": HasRole(roles, role.LavenderAdmin),
}
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)
}
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()
if err != nil {
http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest)

View File

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

View File

@ -10,7 +10,7 @@ import (
"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
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)
}
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()
if err != nil {
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) {
assert.True(t, HasRole("lavender:admin test:something-else", "lavender:admin"))
assert.False(t, HasRole("lavender:admin,test:something-else", "lavender:admin"))
assert.False(t, HasRole("lavender: test:something-else", "lavender:admin"))
assert.True(t, HasRole([]string{"lavender:admin", "test:something-else"}, "lavender:admin"))
assert.False(t, HasRole([]string{"lavender:admin", "test:something-else"}, "lavender:admin"))
assert.False(t, HasRole([]string{"lavender:", "test:something-else"}, "lavender:admin"))
}

View File

@ -1,20 +1,16 @@
package server
import (
"bytes"
"crypto/subtle"
"encoding/json"
"errors"
"github.com/1f349/cache"
clientStore "github.com/1f349/lavender/client-store"
"github.com/1f349/lavender/conf"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/logger"
"github.com/1f349/lavender/openid"
"github.com/1f349/lavender/pages"
scope2 "github.com/1f349/lavender/scope"
"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/server"
"github.com/go-oauth2/oauth2/v4/store"
@ -28,7 +24,7 @@ import (
var errInvalidScope = errors.New("missing required scope")
type HttpServer struct {
type httpServer struct {
r *httprouter.Router
oauthSrv *server.Server
oauthMgr *manage.Manager
@ -36,7 +32,12 @@ type HttpServer struct {
conf conf.Conf
signingKey *mjwt.Issuer
manager *issuer.Manager
flowState *cache.Cache[string, flowStateData]
// flowState contains the
flowState *cache.Cache[string, flowStateData]
// mailLinkCache contains a mapping of verify uuids to user uuids
mailLinkCache *cache.Cache[mailLinkKey, string]
}
type flowStateData struct {
@ -45,52 +46,44 @@ type flowStateData struct {
redirect string
}
func NewHttpServer(config conf.Conf, db *database.Queries, signingKey *mjwt.Issuer) *httprouter.Router {
r := httprouter.New()
type mailLink byte
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()
// remove last slash from baseUrl
{
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,
hs := &httpServer{
r: r,
db: db,
conf: config,
signingKey: signingKey,
flowState: cache.New[string, flowStateData](),
}
hs.manager, err = issuer.NewManager(config.SsoServices)
if err != nil {
logger.Logger.Fatal("Failed to reload SSO service manager", "err", err)
flowState: cache.New[string, flowStateData](),
mailLinkCache: cache.New[mailLinkKey, string](),
}
oauthManager := manage.NewManager()
oauthManager.MapAuthorizeGenerate(generates.NewAuthorizeGenerate())
oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
oauthManager.MustTokenStorage(store.NewMemoryTokenStore())
oauthManager.MapAccessGenerate(NewJWTAccessGenerate(hs.signingKey, db))
oauthManager.MapAccessGenerate(NewMJWTAccessGenerate(signingKey, db))
oauthManager.MapClientStorage(clientStore.New(db))
oauthSrv := server.NewDefaultServer(oauthManager)
oauthSrv.SetClientInfoHandler(func(req *http.Request) (clientID, clientSecret string, err error) {
cId, cSecret, err := server.ClientBasicHandler(req)
if cId == "" && cSecret == "" {
@ -117,47 +110,10 @@ func NewHttpServer(config conf.Conf, db *database.Queries, signingKey *mjwt.Issu
})
addIdTokenSupport(oauthSrv, db, signingKey)
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())
})
r.GET("/", hs.OptionalAuthentication(hs.Home))
ssoManager := issuer.NewManager(config.SsoServices)
// login
r.GET("/login", hs.OptionalAuthentication(hs.loginGet))
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)
}))
SetupOpenId(r, config.BaseUrl, signingKey)
r.POST("/logout", hs.RequireAuthentication(fu))
// theme styles
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)
})
// management pages
r.GET("/manage/apps", hs.RequireAuthentication(hs.ManageAppsGet))
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)
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
SetupManageApps(r)
SetupManageUsers(r)
}
func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) {
func (h *httpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) {
redirectUrl := req.FormValue("redirect")
if redirectUrl == "" {
http.Redirect(rw, req, "/", http.StatusFound)