Closer to lavender v2

This commit is contained in:
Melon 2024-09-02 22:54:03 +01:00
parent 33c7ac9b06
commit 51e33322d3
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
30 changed files with 830 additions and 303 deletions

10
Makefile Normal file
View File

@ -0,0 +1,10 @@
SQL_SRC_DIR := database
SQL_FILES := $(wildcard $(SQL_SRC_DIR)/{migrations,queries}/*.sql)
all: sqlc
sqlc: $(SQL_FILES)
sqlc generate
build: sqlc
go build ./cmd/lavender

View File

@ -1,6 +1,10 @@
package database
import "github.com/go-oauth2/oauth2/v4"
import (
"bufio"
"github.com/go-oauth2/oauth2/v4"
"strings"
)
var _ oauth2.ClientInfo = &ClientStore{}
@ -8,7 +12,7 @@ func (c *ClientStore) GetID() string { return c.Subject }
func (c *ClientStore) GetSecret() string { return c.Secret }
func (c *ClientStore) GetDomain() string { return c.Domain }
func (c *ClientStore) IsPublic() bool { return c.Public }
func (c *ClientStore) GetUserID() string { return c.Owner }
func (c *ClientStore) GetUserID() string { return c.OwnerSubject }
// GetName is an extra field for the oauth handler to display the application
// name
@ -22,4 +26,12 @@ func (c *ClientStore) IsSSO() bool { return c.Sso }
func (c *ClientStore) IsActive() bool { return c.Active }
// UsePerms is an extra field for the userinfo handler to return user permissions matching the requested values
func (c *ClientStore) UsePerms() string { return c.Perms }
func (c *ClientStore) UsePerms() []string {
perms := make([]string, 0)
sc := bufio.NewScanner(strings.NewReader(c.Perms))
sc.Split(bufio.ScanWords)
if sc.Scan() {
perms = append(perms, sc.Text())
}
return perms
}

View File

@ -10,15 +10,22 @@ import (
)
const getAppList = `-- name: GetAppList :many
SELECT subject, name, domain, owner, perms, public, sso, active
SELECT subject,
name,
domain,
owner_subject,
perms,
public,
sso,
active
FROM client_store
WHERE owner = ?
WHERE owner_subject = ?
OR ? = 1
LIMIT 25 OFFSET ?
`
type GetAppListParams struct {
Owner string `json:"owner"`
OwnerSubject string `json:"owner_subject"`
Column2 interface{} `json:"column_2"`
Offset int64 `json:"offset"`
}
@ -27,7 +34,7 @@ type GetAppListRow struct {
Subject string `json:"subject"`
Name string `json:"name"`
Domain string `json:"domain"`
Owner string `json:"owner"`
OwnerSubject string `json:"owner_subject"`
Perms string `json:"perms"`
Public bool `json:"public"`
Sso bool `json:"sso"`
@ -35,7 +42,7 @@ type GetAppListRow struct {
}
func (q *Queries) GetAppList(ctx context.Context, arg GetAppListParams) ([]GetAppListRow, error) {
rows, err := q.db.QueryContext(ctx, getAppList, arg.Owner, arg.Column2, arg.Offset)
rows, err := q.db.QueryContext(ctx, getAppList, arg.OwnerSubject, arg.Column2, arg.Offset)
if err != nil {
return nil, err
}
@ -47,7 +54,7 @@ func (q *Queries) GetAppList(ctx context.Context, arg GetAppListParams) ([]GetAp
&i.Subject,
&i.Name,
&i.Domain,
&i.Owner,
&i.OwnerSubject,
&i.Perms,
&i.Public,
&i.Sso,
@ -67,7 +74,7 @@ func (q *Queries) GetAppList(ctx context.Context, arg GetAppListParams) ([]GetAp
}
const getClientInfo = `-- name: GetClientInfo :one
SELECT subject, name, secret, domain, owner, perms, public, sso, active
SELECT subject, name, secret, domain, owner_subject, perms, public, sso, active
FROM client_store
WHERE subject = ?
LIMIT 1
@ -81,7 +88,7 @@ func (q *Queries) GetClientInfo(ctx context.Context, subject string) (ClientStor
&i.Name,
&i.Secret,
&i.Domain,
&i.Owner,
&i.OwnerSubject,
&i.Perms,
&i.Public,
&i.Sso,
@ -91,7 +98,7 @@ func (q *Queries) GetClientInfo(ctx context.Context, subject string) (ClientStor
}
const insertClientApp = `-- name: InsertClientApp :exec
INSERT INTO client_store (subject, name, secret, domain, owner, perms, public, sso, active)
INSERT INTO client_store (subject, name, secret, domain, perms, public, sso, active, owner_subject)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`
@ -100,11 +107,11 @@ type InsertClientAppParams struct {
Name string `json:"name"`
Secret string `json:"secret"`
Domain string `json:"domain"`
Owner string `json:"owner"`
Perms string `json:"perms"`
Public bool `json:"public"`
Sso bool `json:"sso"`
Active bool `json:"active"`
OwnerSubject string `json:"owner_subject"`
}
func (q *Queries) InsertClientApp(ctx context.Context, arg InsertClientAppParams) error {
@ -113,11 +120,11 @@ func (q *Queries) InsertClientApp(ctx context.Context, arg InsertClientAppParams
arg.Name,
arg.Secret,
arg.Domain,
arg.Owner,
arg.Perms,
arg.Public,
arg.Sso,
arg.Active,
arg.OwnerSubject,
)
return err
}
@ -126,17 +133,17 @@ const resetClientAppSecret = `-- name: ResetClientAppSecret :exec
UPDATE client_store
SET secret = ?
WHERE subject = ?
AND owner = ?
AND owner_subject = ?
`
type ResetClientAppSecretParams struct {
Secret string `json:"secret"`
Subject string `json:"subject"`
Owner string `json:"owner"`
OwnerSubject string `json:"owner_subject"`
}
func (q *Queries) ResetClientAppSecret(ctx context.Context, arg ResetClientAppSecretParams) error {
_, err := q.db.ExecContext(ctx, resetClientAppSecret, arg.Secret, arg.Subject, arg.Owner)
_, err := q.db.ExecContext(ctx, resetClientAppSecret, arg.Secret, arg.Subject, arg.OwnerSubject)
return err
}
@ -149,7 +156,7 @@ SET name = ?,
sso = ?,
active = ?
WHERE subject = ?
AND owner = ?
AND owner_subject = ?
`
type UpdateClientAppParams struct {
@ -161,7 +168,7 @@ type UpdateClientAppParams struct {
Sso bool `json:"sso"`
Active bool `json:"active"`
Subject string `json:"subject"`
Owner string `json:"owner"`
OwnerSubject string `json:"owner_subject"`
}
func (q *Queries) UpdateClientApp(ctx context.Context, arg UpdateClientAppParams) error {
@ -174,7 +181,7 @@ func (q *Queries) UpdateClientApp(ctx context.Context, arg UpdateClientAppParams
arg.Sso,
arg.Active,
arg.Subject,
arg.Owner,
arg.OwnerSubject,
)
return err
}

View File

@ -7,26 +7,50 @@ package database
import (
"context"
"strings"
"time"
)
const changeUserActive = `-- name: ChangeUserActive :exec
UPDATE users
SET active = cast(? as boolean)
WHERE subject = ?
`
type ChangeUserActiveParams struct {
Column1 bool `json:"column_1"`
Subject string `json:"subject"`
}
func (q *Queries) ChangeUserActive(ctx context.Context, arg ChangeUserActiveParams) error {
_, err := q.db.ExecContext(ctx, changeUserActive, arg.Column1, arg.Subject)
return err
}
const getUserList = `-- name: GetUserList :many
SELECT subject,
SELECT users.subject,
name,
picture,
website,
email,
email_verified,
roles,
updated_at,
users.updated_at as user_updated_at,
p.updated_at as profile_updated_at,
active
FROM users
LIMIT 25 OFFSET ?
INNER JOIN main.profiles p on users.subject = p.subject
LIMIT 50 OFFSET ?
`
type GetUserListRow struct {
Subject string `json:"subject"`
Name string `json:"name"`
Picture string `json:"picture"`
Website string `json:"website"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Roles string `json:"roles"`
UpdatedAt time.Time `json:"updated_at"`
UserUpdatedAt time.Time `json:"user_updated_at"`
ProfileUpdatedAt time.Time `json:"profile_updated_at"`
Active bool `json:"active"`
}
@ -41,10 +65,13 @@ func (q *Queries) GetUserList(ctx context.Context, offset int64) ([]GetUserListR
var i GetUserListRow
if err := rows.Scan(
&i.Subject,
&i.Name,
&i.Picture,
&i.Website,
&i.Email,
&i.EmailVerified,
&i.Roles,
&i.UpdatedAt,
&i.UserUpdatedAt,
&i.ProfileUpdatedAt,
&i.Active,
); err != nil {
return nil, err
@ -60,22 +87,50 @@ func (q *Queries) GetUserList(ctx context.Context, offset int64) ([]GetUserListR
return items, nil
}
const updateUser = `-- name: UpdateUser :exec
UPDATE users
SET active = ?,
roles=?
WHERE subject = ?
const getUsersRoles = `-- name: GetUsersRoles :many
SELECT r.role, u.id
FROM users_roles
INNER JOIN roles r on r.id = users_roles.role_id
INNER JOIN users u on u.id = users_roles.user_id
WHERE u.id in /*SLICE:user_ids*/?
`
type UpdateUserParams struct {
Active bool `json:"active"`
Roles string `json:"roles"`
Subject string `json:"subject"`
type GetUsersRolesRow struct {
Role string `json:"role"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error {
_, err := q.db.ExecContext(ctx, updateUser, arg.Active, arg.Roles, arg.Subject)
return err
func (q *Queries) GetUsersRoles(ctx context.Context, userIds []int64) ([]GetUsersRolesRow, error) {
query := getUsersRoles
var queryParams []interface{}
if len(userIds) > 0 {
for _, v := range userIds {
queryParams = append(queryParams, v)
}
query = strings.Replace(query, "/*SLICE:user_ids*/?", strings.Repeat(",?", len(userIds))[1:], 1)
} else {
query = strings.Replace(query, "/*SLICE:user_ids*/?", "NULL", 1)
}
rows, err := q.db.QueryContext(ctx, query, queryParams...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetUsersRolesRow
for rows.Next() {
var i GetUsersRolesRow
if err := rows.Scan(&i.Role, &i.ID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const userEmailExists = `-- name: UserEmailExists :one

View File

@ -1,2 +0,0 @@
DROP TABLE users;
DROP TABLE client_store;

View File

@ -1,27 +0,0 @@
CREATE TABLE users
(
subject TEXT PRIMARY KEY UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
email_verified BOOLEAN DEFAULT 0 NOT NULL,
roles TEXT NOT NULL,
userinfo TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
expiry DATETIME,
updated_at DATETIME NOT NULL,
active BOOLEAN DEFAULT 1 NOT NULL
);
CREATE TABLE client_store
(
subject TEXT PRIMARY KEY UNIQUE NOT NULL,
name TEXT NOT NULL,
secret TEXT UNIQUE NOT NULL,
domain TEXT NOT NULL,
owner TEXT NOT NULL,
perms TEXT NOT NULL,
public BOOLEAN NOT NULL,
sso BOOLEAN NOT NULL,
active BOOLEAN DEFAULT 1 NOT NULL,
FOREIGN KEY (owner) REFERENCES users (subject)
);

View File

@ -0,0 +1,5 @@
DROP TABLE users;
DROP INDEX username_index;
DROP TABLE roles;
DROP TABLE otp;
DROP TABLE client_store;

View File

@ -0,0 +1,69 @@
CREATE TABLE users
(
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,
updated_at DATETIME NOT NULL,
registered DATETIME NOT NULL,
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,
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,
role TEXT NOT NULL UNIQUE
);
CREATE TABLE users_roles
(
role_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
FOREIGN KEY (role_id) REFERENCES roles (id),
FOREIGN KEY (user_id) REFERENCES users (id),
CONSTRAINT user_role UNIQUE (role_id, user_id)
);
CREATE TABLE otp
(
subject INTEGER NOT NULL UNIQUE PRIMARY KEY,
secret TEXT NOT NULL,
digits INTEGER NOT NULL,
FOREIGN KEY (subject) REFERENCES users (subject)
);
CREATE TABLE client_store
(
subject TEXT NOT NULL UNIQUE PRIMARY KEY,
name TEXT NOT NULL,
secret TEXT NOT NULL UNIQUE,
domain TEXT NOT NULL,
owner_subject TEXT NOT NULL,
perms TEXT NOT NULL,
public BOOLEAN NOT NULL,
sso BOOLEAN NOT NULL,
active BOOLEAN NOT NULL DEFAULT 1,
FOREIGN KEY (owner_subject) REFERENCES users (subject)
);

View File

@ -5,8 +5,9 @@
package database
import (
"database/sql"
"time"
"github.com/1f349/lavender/password"
)
type ClientStore struct {
@ -14,22 +15,48 @@ type ClientStore struct {
Name string `json:"name"`
Secret string `json:"secret"`
Domain string `json:"domain"`
Owner string `json:"owner"`
OwnerSubject string `json:"owner_subject"`
Perms string `json:"perms"`
Public bool `json:"public"`
Sso bool `json:"sso"`
Active bool `json:"active"`
}
type User struct {
type Otp struct {
Subject int64 `json:"subject"`
Secret string `json:"secret"`
Digits int64 `json:"digits"`
}
type Profile struct {
Subject string `json:"subject"`
Name string `json:"name"`
Picture string `json:"picture"`
Website string `json:"website"`
Pronouns string `json:"pronouns"`
Birthdate interface{} `json:"birthdate"`
Zone string `json:"zone"`
Locale string `json:"locale"`
UpdatedAt time.Time `json:"updated_at"`
}
type Role struct {
ID int64 `json:"id"`
Role string `json:"role"`
}
type User struct {
ID int64 `json:"id"`
Subject string `json:"subject"`
Password password.HashString `json:"password"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Roles string `json:"roles"`
Userinfo string `json:"userinfo"`
AccessToken sql.NullString `json:"access_token"`
RefreshToken sql.NullString `json:"refresh_token"`
Expiry sql.NullTime `json:"expiry"`
UpdatedAt time.Time `json:"updated_at"`
Registered time.Time `json:"registered"`
Active bool `json:"active"`
}
type UsersRole struct {
RoleID int64 `json:"role_id"`
UserID int64 `json:"user_id"`
}

81
database/otp.sql.go Normal file
View File

@ -0,0 +1,81 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.25.0
// source: otp.sql
package database
import (
"context"
)
const deleteOtp = `-- name: DeleteOtp :exec
DELETE
FROM otp
WHERE otp.subject = ?
`
func (q *Queries) DeleteOtp(ctx context.Context, subject int64) error {
_, err := q.db.ExecContext(ctx, deleteOtp, subject)
return err
}
const getOtp = `-- name: GetOtp :one
SELECT secret, digits
FROM otp
WHERE subject = ?
`
type GetOtpRow struct {
Secret string `json:"secret"`
Digits int64 `json:"digits"`
}
func (q *Queries) GetOtp(ctx context.Context, subject int64) (GetOtpRow, error) {
row := q.db.QueryRowContext(ctx, getOtp, subject)
var i GetOtpRow
err := row.Scan(&i.Secret, &i.Digits)
return i, err
}
const getUserEmail = `-- name: GetUserEmail :one
SELECT email
FROM users
WHERE subject = ?
`
func (q *Queries) GetUserEmail(ctx context.Context, subject string) (string, error) {
row := q.db.QueryRowContext(ctx, getUserEmail, subject)
var email string
err := row.Scan(&email)
return email, err
}
const hasOtp = `-- name: HasOtp :one
SELECT EXISTS(SELECT 1 FROM otp WHERE subject = ?) == 1 as hasOtp
`
func (q *Queries) HasOtp(ctx context.Context, subject int64) (bool, error) {
row := q.db.QueryRowContext(ctx, hasOtp, subject)
var hasotp bool
err := row.Scan(&hasotp)
return hasotp, err
}
const setOtp = `-- name: SetOtp :exec
INSERT OR
REPLACE
INTO otp (subject, secret, digits)
VALUES (?, ?, ?)
`
type SetOtpParams struct {
Subject int64 `json:"subject"`
Secret string `json:"secret"`
Digits int64 `json:"digits"`
}
func (q *Queries) SetOtp(ctx context.Context, arg SetOtpParams) error {
_, err := q.db.ExecContext(ctx, setOtp, arg.Subject, arg.Secret, arg.Digits)
return err
}

View File

@ -0,0 +1,78 @@
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"`
}
func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) (string, error) {
pwHash, err := password.HashPassword(arg.Password)
if err != nil {
return "", err
}
n := time.Now()
a := addUserParams{
Subject: uuid.NewString(),
Password: pwHash,
Email: arg.Email,
EmailVerified: false,
UpdatedAt: n,
Registered: n,
Active: true,
}
return a.Subject, q.addUser(ctx, a)
}
type CheckLoginResult struct {
Subject string `json:"subject"`
HasOtp bool `json:"has_otp"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
func (q *Queries) CheckLogin(ctx context.Context, un, pw string) (CheckLoginResult, error) {
login, err := q.checkLogin(ctx, un)
if err != nil {
return CheckLoginResult{}, err
}
err = password.CheckPasswordHash(login.Password, pw)
if err != nil {
return CheckLoginResult{}, err
}
return CheckLoginResult{
Subject: login.Subject,
HasOtp: login.HasOtp,
Email: login.Email,
EmailVerified: login.EmailVerified,
}, nil
}
func (q *Queries) ChangePassword(ctx context.Context, subject, newPw string) error {
userPassword, err := q.getUserPassword(ctx, subject)
if err != nil {
return err
}
newPwHash, err := password.HashPassword(newPw)
if err != nil {
return err
}
return q.changeUserPassword(ctx, changeUserPasswordParams{
Password: newPwHash,
UpdatedAt: time.Now(),
Subject: subject,
Password_2: userPassword,
})
}

62
database/profile-patch.go Normal file
View File

@ -0,0 +1,62 @@
package database
import (
"fmt"
"github.com/1f349/lavender/database/types"
"github.com/hardfinhq/go-date"
"github.com/mrmelon54/pronouns"
"golang.org/x/text/language"
"net/url"
"time"
)
type ProfilePatch struct {
Name string `json:"name"`
Picture string `json:"picture"`
Website string `json:"website"`
Pronouns types.UserPronoun `json:"pronouns"`
Birthdate date.NullDate `json:"birthdate"`
Zone types.UserZone `json:"zone"`
Locale types.UserLocale `json:"locale"`
}
func (p *ProfilePatch) ParseFromForm(v url.Values) (safeErrs []error) {
var err error
p.Name = v.Get("name")
p.Picture = v.Get("picture")
p.Website = v.Get("website")
if v.Has("reset_pronouns") {
p.Pronouns.Pronoun = pronouns.TheyThem
} else {
p.Pronouns.Pronoun, err = pronouns.FindPronoun(v.Get("pronouns"))
if err != nil {
safeErrs = append(safeErrs, fmt.Errorf("invalid pronoun selected"))
}
}
if v.Has("reset_birthdate") || v.Get("birthdate") == "" {
p.Birthdate = date.NullDate{}
} else {
p.Birthdate = date.NullDate{Valid: true}
p.Birthdate.Date, err = date.FromString(v.Get("birthdate"))
if err != nil {
safeErrs = append(safeErrs, fmt.Errorf("invalid time selected"))
}
}
if v.Has("reset_zoneinfo") {
p.Zone.Location = time.UTC
} else {
p.Zone.Location, err = time.LoadLocation(v.Get("zoneinfo"))
if err != nil {
safeErrs = append(safeErrs, fmt.Errorf("invalid timezone selected"))
}
}
if v.Has("reset_locale") {
p.Locale.Tag = language.AmericanEnglish
} else {
p.Locale.Tag, err = language.Parse(v.Get("locale"))
if err != nil {
safeErrs = append(safeErrs, fmt.Errorf("invalid language selected"))
}
}
return
}

74
database/profiles.sql.go Normal file
View File

@ -0,0 +1,74 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.25.0
// source: profiles.sql
package database
import (
"context"
"time"
)
const getProfile = `-- name: GetProfile :one
SELECT profiles.subject, profiles.name, profiles.picture, profiles.website, profiles.pronouns, profiles.birthdate, profiles.zone, profiles.locale, profiles.updated_at
FROM profiles
WHERE subject = ?
`
func (q *Queries) GetProfile(ctx context.Context, subject string) (Profile, error) {
row := q.db.QueryRowContext(ctx, getProfile, subject)
var i Profile
err := row.Scan(
&i.Subject,
&i.Name,
&i.Picture,
&i.Website,
&i.Pronouns,
&i.Birthdate,
&i.Zone,
&i.Locale,
&i.UpdatedAt,
)
return i, err
}
const modifyProfile = `-- name: ModifyProfile :exec
UPDATE profiles
SET name = ?,
picture = ?,
website = ?,
pronouns = ?,
birthdate = ?,
zone = ?,
locale = ?,
updated_at = ?
WHERE subject = ?
`
type ModifyProfileParams struct {
Name string `json:"name"`
Picture string `json:"picture"`
Website string `json:"website"`
Pronouns string `json:"pronouns"`
Birthdate interface{} `json:"birthdate"`
Zone string `json:"zone"`
Locale string `json:"locale"`
UpdatedAt time.Time `json:"updated_at"`
Subject string `json:"subject"`
}
func (q *Queries) ModifyProfile(ctx context.Context, arg ModifyProfileParams) error {
_, err := q.db.ExecContext(ctx, modifyProfile,
arg.Name,
arg.Picture,
arg.Website,
arg.Pronouns,
arg.Birthdate,
arg.Zone,
arg.Locale,
arg.UpdatedAt,
arg.Subject,
)
return err
}

View File

@ -5,14 +5,21 @@ WHERE subject = ?
LIMIT 1;
-- name: GetAppList :many
SELECT subject, name, domain, owner, perms, public, sso, active
SELECT subject,
name,
domain,
owner_subject,
perms,
public,
sso,
active
FROM client_store
WHERE owner = ?
WHERE owner_subject = ?
OR ? = 1
LIMIT 25 OFFSET ?;
-- name: InsertClientApp :exec
INSERT INTO client_store (subject, name, secret, domain, owner, perms, public, sso, active)
INSERT INTO client_store (subject, name, secret, domain, perms, public, sso, active, owner_subject)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
-- name: UpdateClientApp :exec
@ -24,10 +31,10 @@ SET name = ?,
sso = ?,
active = ?
WHERE subject = ?
AND owner = ?;
AND owner_subject = ?;
-- name: ResetClientAppSecret :exec
UPDATE client_store
SET secret = ?
WHERE subject = ?
AND owner = ?;
AND owner_subject = ?;

View File

@ -1,17 +1,27 @@
-- name: GetUserList :many
SELECT subject,
SELECT users.subject,
name,
picture,
website,
email,
email_verified,
roles,
updated_at,
users.updated_at as user_updated_at,
p.updated_at as profile_updated_at,
active
FROM users
LIMIT 25 OFFSET ?;
INNER JOIN main.profiles p on users.subject = p.subject
LIMIT 50 OFFSET ?;
-- name: UpdateUser :exec
-- name: GetUsersRoles :many
SELECT r.role, u.id
FROM users_roles
INNER JOIN roles r on r.id = users_roles.role_id
INNER JOIN users u on u.id = users_roles.user_id
WHERE u.id in sqlc.slice(user_ids);
-- name: ChangeUserActive :exec
UPDATE users
SET active = ?,
roles=?
SET active = cast(? as boolean)
WHERE subject = ?;
-- name: UserEmailExists :one

23
database/queries/otp.sql Normal file
View File

@ -0,0 +1,23 @@
-- name: SetOtp :exec
INSERT OR
REPLACE
INTO otp (subject, secret, digits)
VALUES (?, ?, ?);
-- name: DeleteOtp :exec
DELETE
FROM otp
WHERE otp.subject = ?;
-- name: GetOtp :one
SELECT secret, digits
FROM otp
WHERE subject = ?;
-- name: HasOtp :one
SELECT EXISTS(SELECT 1 FROM otp WHERE subject = ?) == 1 as hasOtp;
-- name: GetUserEmail :one
SELECT email
FROM users
WHERE subject = ?;

View File

@ -0,0 +1,16 @@
-- name: GetProfile :one
SELECT profiles.*
FROM profiles
WHERE subject = ?;
-- name: ModifyProfile :exec
UPDATE profiles
SET name = ?,
picture = ?,
website = ?,
pronouns = ?,
birthdate = ?,
zone = ?,
locale = ?,
updated_at = ?
WHERE subject = ?;

View File

@ -2,21 +2,15 @@
SELECT count(subject) > 0 AS hasUser
FROM users;
-- name: AddUser :exec
INSERT INTO users (subject, email, email_verified, roles, userinfo, updated_at, active)
-- name: addUser :exec
INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active)
VALUES (?, ?, ?, ?, ?, ?, ?);
-- name: UpdateUserInfo :exec
UPDATE users
SET email = ?,
email_verified = ?,
userinfo = ?
WHERE subject = ?;
-- name: GetUserRoles :one
SELECT roles
-- 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
WHERE subject = ?;
WHERE users.subject = ?
LIMIT 1;
-- name: GetUser :one
SELECT *
@ -24,20 +18,29 @@ FROM users
WHERE subject = ?
LIMIT 1;
-- name: UpdateUserToken :exec
-- name: GetUserRoles :many
SELECT r.role
FROM users_roles
INNER JOIN roles r on r.id = users_roles.role_id
INNER JOIN users u on u.id = users_roles.user_id
WHERE u.subject = ?;
-- name: UserHasRole :one
SELECT 1
FROM roles
INNER JOIN users_roles on users_roles.user_id = roles.id
INNER JOIN users u on u.id = users_roles.user_id = u.id
WHERE roles.role = ?
AND u.subject = ?;
-- name: getUserPassword :one
SELECT password
FROM users
WHERE subject = ?;
-- name: changeUserPassword :exec
UPDATE users
SET access_token = ?,
refresh_token = ?,
expiry = ?
WHERE subject = ?;
-- name: GetUserToken :one
SELECT access_token, refresh_token, expiry
FROM users
SET password = ?,
updated_at=?
WHERE subject = ?
LIMIT 1;
-- name: GetUserEmail :one
SELECT email
FROM users
WHERE subject = ?;
AND password = ?;

View File

@ -7,40 +7,13 @@ package database
import (
"context"
"database/sql"
"time"
"github.com/1f349/lavender/password"
)
const addUser = `-- name: AddUser :exec
INSERT INTO users (subject, email, email_verified, roles, userinfo, updated_at, active)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
type AddUserParams struct {
Subject string `json:"subject"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Roles string `json:"roles"`
Userinfo string `json:"userinfo"`
UpdatedAt time.Time `json:"updated_at"`
Active bool `json:"active"`
}
func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) error {
_, err := q.db.ExecContext(ctx, addUser,
arg.Subject,
arg.Email,
arg.EmailVerified,
arg.Roles,
arg.Userinfo,
arg.UpdatedAt,
arg.Active,
)
return err
}
const getUser = `-- name: GetUser :one
SELECT subject, email, email_verified, roles, userinfo, access_token, refresh_token, expiry, updated_at, active
SELECT id, subject, password, email, email_verified, updated_at, registered, active
FROM users
WHERE subject = ?
LIMIT 1
@ -50,64 +23,47 @@ func (q *Queries) GetUser(ctx context.Context, subject string) (User, error) {
row := q.db.QueryRowContext(ctx, getUser, subject)
var i User
err := row.Scan(
&i.ID,
&i.Subject,
&i.Password,
&i.Email,
&i.EmailVerified,
&i.Roles,
&i.Userinfo,
&i.AccessToken,
&i.RefreshToken,
&i.Expiry,
&i.UpdatedAt,
&i.Registered,
&i.Active,
)
return i, err
}
const getUserEmail = `-- name: GetUserEmail :one
SELECT email
FROM users
WHERE subject = ?
const getUserRoles = `-- name: GetUserRoles :many
SELECT r.role
FROM users_roles
INNER JOIN roles r on r.id = users_roles.role_id
INNER JOIN users u on u.id = users_roles.user_id
WHERE u.subject = ?
`
func (q *Queries) GetUserEmail(ctx context.Context, subject string) (string, error) {
row := q.db.QueryRowContext(ctx, getUserEmail, subject)
var email string
err := row.Scan(&email)
return email, err
func (q *Queries) GetUserRoles(ctx context.Context, subject string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getUserRoles, subject)
if err != nil {
return nil, err
}
const getUserRoles = `-- name: GetUserRoles :one
SELECT roles
FROM users
WHERE subject = ?
`
func (q *Queries) GetUserRoles(ctx context.Context, subject string) (string, error) {
row := q.db.QueryRowContext(ctx, getUserRoles, subject)
var roles string
err := row.Scan(&roles)
return roles, err
defer rows.Close()
var items []string
for rows.Next() {
var role string
if err := rows.Scan(&role); err != nil {
return nil, err
}
const getUserToken = `-- name: GetUserToken :one
SELECT access_token, refresh_token, expiry
FROM users
WHERE subject = ?
LIMIT 1
`
type GetUserTokenRow struct {
AccessToken sql.NullString `json:"access_token"`
RefreshToken sql.NullString `json:"refresh_token"`
Expiry sql.NullTime `json:"expiry"`
items = append(items, role)
}
func (q *Queries) GetUserToken(ctx context.Context, subject string) (GetUserTokenRow, error) {
row := q.db.QueryRowContext(ctx, getUserToken, subject)
var i GetUserTokenRow
err := row.Scan(&i.AccessToken, &i.RefreshToken, &i.Expiry)
return i, err
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const hasUser = `-- name: HasUser :one
@ -122,52 +78,117 @@ func (q *Queries) HasUser(ctx context.Context) (bool, error) {
return hasuser, err
}
const updateUserInfo = `-- name: UpdateUserInfo :exec
UPDATE users
SET email = ?,
email_verified = ?,
userinfo = ?
WHERE subject = ?
const userHasRole = `-- name: UserHasRole :one
SELECT 1
FROM roles
INNER JOIN users_roles on users_roles.user_id = roles.id
INNER JOIN users u on u.id = users_roles.user_id = u.id
WHERE roles.role = ?
AND u.subject = ?
`
type UpdateUserInfoParams struct {
type UserHasRoleParams struct {
Role string `json:"role"`
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
}
const addUser = `-- name: addUser :exec
INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
type addUserParams struct {
Subject string `json:"subject"`
Password password.HashString `json:"password"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Userinfo string `json:"userinfo"`
Subject string `json:"subject"`
UpdatedAt time.Time `json:"updated_at"`
Registered time.Time `json:"registered"`
Active bool `json:"active"`
}
func (q *Queries) UpdateUserInfo(ctx context.Context, arg UpdateUserInfoParams) error {
_, err := q.db.ExecContext(ctx, updateUserInfo,
func (q *Queries) addUser(ctx context.Context, arg addUserParams) error {
_, err := q.db.ExecContext(ctx, addUser,
arg.Subject,
arg.Password,
arg.Email,
arg.EmailVerified,
arg.Userinfo,
arg.Subject,
arg.UpdatedAt,
arg.Registered,
arg.Active,
)
return err
}
const updateUserToken = `-- name: UpdateUserToken :exec
const changeUserPassword = `-- name: changeUserPassword :exec
UPDATE users
SET access_token = ?,
refresh_token = ?,
expiry = ?
SET password = ?,
updated_at=?
WHERE subject = ?
AND password = ?
`
type changeUserPasswordParams struct {
Password password.HashString `json:"password"`
UpdatedAt time.Time `json:"updated_at"`
Subject string `json:"subject"`
Password_2 password.HashString `json:"password_2"`
}
func (q *Queries) changeUserPassword(ctx context.Context, arg changeUserPasswordParams) error {
_, err := q.db.ExecContext(ctx, changeUserPassword,
arg.Password,
arg.UpdatedAt,
arg.Subject,
arg.Password_2,
)
return err
}
const checkLogin = `-- 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
WHERE users.subject = ?
LIMIT 1
`
type checkLoginRow struct {
Subject string `json:"subject"`
Password password.HashString `json:"password"`
HasOtp bool `json:"has_otp"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
func (q *Queries) checkLogin(ctx context.Context, subject string) (checkLoginRow, error) {
row := q.db.QueryRowContext(ctx, checkLogin, subject)
var i checkLoginRow
err := row.Scan(
&i.Subject,
&i.Password,
&i.HasOtp,
&i.Email,
&i.EmailVerified,
)
return i, err
}
const getUserPassword = `-- name: getUserPassword :one
SELECT password
FROM users
WHERE subject = ?
`
type UpdateUserTokenParams struct {
AccessToken sql.NullString `json:"access_token"`
RefreshToken sql.NullString `json:"refresh_token"`
Expiry sql.NullTime `json:"expiry"`
Subject string `json:"subject"`
}
func (q *Queries) UpdateUserToken(ctx context.Context, arg UpdateUserTokenParams) error {
_, err := q.db.ExecContext(ctx, updateUserToken,
arg.AccessToken,
arg.RefreshToken,
arg.Expiry,
arg.Subject,
)
return err
func (q *Queries) getUserPassword(ctx context.Context, subject string) (password.HashString, error) {
row := q.db.QueryRowContext(ctx, getUserPassword, subject)
var password password.HashString
err := row.Scan(&password)
return password, err
}

1
go.mod
View File

@ -17,6 +17,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.17.1
github.com/google/subcommands v1.2.0
github.com/google/uuid v1.6.0
github.com/hardfinhq/go-date v1.20240411.1
github.com/julienschmidt/httprouter v1.3.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/mrmelon54/pronouns v1.0.3

2
go.sum
View File

@ -83,6 +83,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hardfinhq/go-date v1.20240411.1 h1:UskRXxgD+4eCEa8CpiiWLiv2/Vnf1eB90Bojkf7AQ64=
github.com/hardfinhq/go-date v1.20240411.1/go.mod h1:7oxaI9XX4W3/MRDeQXec0fLXFnSJDS7BrazIY2XqPXA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=

View File

@ -26,10 +26,9 @@ func TestManager_CheckNamespace(t *testing.T) {
httpGet = func(url string) (resp *http.Response, err error) {
return &http.Response{StatusCode: http.StatusOK, Body: testBody()}, nil
}
manager, err := NewManager([]SsoConfig{
{
manager, err := NewManager(map[string]SsoConfig{
"example.com": {
Addr: testAddrUrl,
Namespace: "example.com",
},
})
assert.NoError(t, err)
@ -41,10 +40,9 @@ func TestManager_FindServiceFromLogin(t *testing.T) {
httpGet = func(url string) (resp *http.Response, err error) {
return &http.Response{StatusCode: http.StatusOK, Body: testBody()}, nil
}
manager, err := NewManager([]SsoConfig{
{
manager, err := NewManager(map[string]SsoConfig{
"example.com": {
Addr: testAddrUrl,
Namespace: "example.com",
},
})
assert.NoError(t, err)

View File

@ -62,6 +62,7 @@ func (s SsoConfig) FetchConfig() (*WellKnownOIDC, error) {
}
type WellKnownOIDC struct {
Namespace string `json:"-"`
Config SsoConfig `json:"-"`
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`

View File

@ -23,7 +23,7 @@ var ErrAuthHttpError = errors.New("auth http error")
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 roles []string
if h.DbTx(rw, func(tx *database.Queries) (err error) {
roles, err = tx.GetUserRoles(req.Context(), auth.Subject)
return

View File

@ -30,9 +30,9 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute
var isAdmin bool
h.DbTx(rw, func(tx *database.Queries) (err error) {
roles, err := tx.GetUserRoles(req.Context(), auth.Subject)
isAdmin = HasRole(roles, "lavender:admin")
return err
_, err = tx.UserHasRole(req.Context(), database.UserHasRoleParams{Role: "lavender:admin", Subject: auth.Subject})
isAdmin = err == nil
return nil
})
pages.RenderPageTemplate(rw, "index", map[string]any{

View File

@ -30,9 +30,12 @@ func (j *JWTAccessGenerate) Token(ctx context.Context, data *oauth2.GenerateBasi
return "", "", err
}
ps := auth.ParsePermStorage(roles)
ps := auth.NewPermStorage()
for _, role := range roles {
ps.Set(role)
}
out := auth.NewPermStorage()
ForEachRole(data.Client.(interface{ UsePerms() string }).UsePerms(), func(role string) {
ForEachRole(data.Client.(interface{ UsePerms() []string }).UsePerms(), func(role string) {
for _, i := range ps.Filter(strings.Split(role, " ")).Dump() {
out.Set(i)
}

View File

@ -1,25 +1,16 @@
package server
import (
"bufio"
"strings"
)
func HasRole(roles, test string) bool {
sc := bufio.NewScanner(strings.NewReader(roles))
sc.Split(bufio.ScanWords)
for sc.Scan() {
if sc.Text() == test {
func HasRole(roles []string, test string) bool {
for _, role := range roles {
if role == test {
return true
}
}
return false
}
func ForEachRole(roles string, next func(role string)) {
sc := bufio.NewScanner(strings.NewReader(roles))
sc.Split(bufio.ScanWords)
for sc.Scan() {
next(sc.Text())
func ForEachRole(roles []string, next func(role string)) {
for _, role := range roles {
next(role)
}
}

View File

@ -10,14 +10,14 @@ sql:
emit_json_tags: true
overrides:
- column: "users.password"
go_type: "github.com/1f349/tulip/password.HashString"
go_type: "github.com/1f349/lavender/password.HashString"
- column: "users.birthdate"
go_type: "github.com/hardfinhq/go-date.NullDate"
- column: "users.role"
go_type: "github.com/1f349/tulip/database/types.UserRole"
go_type: "github.com/1f349/lavender/database/types.UserRole"
- column: "users.pronouns"
go_type: "github.com/1f349/tulip/database/types.UserPronoun"
go_type: "github.com/1f349/lavender/database/types.UserPronoun"
- column: "users.zoneinfo"
go_type: "github.com/1f349/tulip/database/types.UserZone"
go_type: "github.com/1f349/lavender/database/types.UserZone"
- column: "users.locale"
go_type: "github.com/1f349/tulip/database/types.UserLocale"
go_type: "github.com/1f349/lavender/database/types.UserLocale"