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

@ -13,5 +13,5 @@ type Conf struct {
Kid string `yaml:"kid"`
Namespace string `yaml:"namespace"`
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,
password TEXT NOT NULL,
change_password BOOLEAN NOT NULL,
email TEXT NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT 0,
email_verified BOOLEAN NOT NULL,
updated_at 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,
picture TEXT NOT NULL DEFAULT '',
website TEXT NOT NULL DEFAULT '',
pronouns TEXT NOT NULL DEFAULT 'they/them',
birthdate DATE NULL,
birthdate DATE NULL DEFAULT NULL,
zone TEXT NOT NULL DEFAULT 'UTC',
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
(
id INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT,

View File

@ -2,7 +2,6 @@ package database
import (
"context"
"github.com/1f349/lavender/database/types"
"github.com/1f349/lavender/password"
"github.com/google/uuid"
"time"
@ -10,10 +9,10 @@ import (
type AddUserParams struct {
Name string `json:"name"`
Username string `json:"username"`
Subject string `json:"subject"`
Password string `json:"password"`
Email string `json:"email"`
Role types.UserRole `json:"role"`
EmailVerified bool `json:"email_verified"`
UpdatedAt time.Time `json:"updated_at"`
Active bool `json:"active"`
}
@ -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

@ -18,6 +18,7 @@ var httpGet = http.Get
// The path `/.well-known/openid-configuration` should be available
type SsoConfig struct {
Addr utils.JsonUrl `json:"addr"` // https://login.example.com
Namespace string `json:"namespace"` // example.com
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
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"
@ -13,24 +16,41 @@ type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprout
type UserAuth struct {
Subject string
DisplayName string
UserInfo UserInfoFields
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 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](),
mailLinkCache: cache.New[mailLinkKey, string](),
}
hs.manager, err = issuer.NewManager(config.SsoServices)
if err != nil {
logger.Logger.Fatal("Failed to reload SSO service manager", "err", err)
}
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
SetupManageApps(r)
SetupManageUsers(r)
}
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
}
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)