some stuff

This commit is contained in:
Melon 2024-08-29 17:57:31 +01:00
parent a81aa0458a
commit 33c7ac9b06
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
18 changed files with 303 additions and 18 deletions

View File

@ -1,3 +1,7 @@
# Lavender
An authentication source for multiple login services to be used with a frontend single page application.
A login service with OpenID, OAuth2 and SSO support.
Login via third-party services.
Enables easy use of a single authentication source for a network of services.

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 []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 map[string]issuer.SsoConfig `yaml:"ssoServices"`
}

47
config.example.yml Normal file
View File

@ -0,0 +1,47 @@
# address to listen on
listen: ':9090'
# url for absolute links to the login service
baseUrl: 'http://localhost:9090'
# human-readable service name
serviceName: 'Example Login'
# name of the login issuer
issuer: 'id.example.com'
# id of the private key in the keystore
kid: 'fdd2eb6d-b469-44c8-b15b-495bcf34dae4'
# defines the domain part of login name `user@example.com`
namespace: 'example.com'
# configure automated emails
mail:
name: 'Example Login'
tls: true
server: 'smtp.example.com:465'
from: 'Example Login <noreply@id.example.com>'
username: 'noreply@id.example.com'
password: '#####'
# enable local accounts
localLogin: true
# configure SSO login services
ssoServices:
example.net:
addr: 'https://example.net'
client:
id: 'dcea4be8-dff4-49d2-a5e6-c1b202403714'
secret: '#####'
scopes:
- openid
- name
- username
- profile
- email
- birthdate
- age
- zoneinfo
- locale

View File

@ -0,0 +1,46 @@
package types
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"golang.org/x/text/language"
)
var (
_ sql.Scanner = &UserLocale{}
_ driver.Valuer = &UserLocale{}
_ json.Marshaler = &UserLocale{}
_ json.Unmarshaler = &UserLocale{}
)
type UserLocale struct{ language.Tag }
func (l *UserLocale) Scan(src any) error {
s, ok := src.(string)
if !ok {
return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type %T", src, l)
}
lang, err := language.Parse(s)
if err != nil {
return err
}
l.Tag = lang
return nil
}
func (l UserLocale) Value() (driver.Value, error) {
return l.Tag.String(), nil
}
func (l UserLocale) MarshalJSON() ([]byte, error) { return json.Marshal(l.Tag.String()) }
func (l *UserLocale) UnmarshalJSON(bytes []byte) error {
var a string
err := json.Unmarshal(bytes, &a)
if err != nil {
return err
}
return l.Scan(a)
}

View File

@ -0,0 +1,12 @@
package types
import (
"github.com/stretchr/testify/assert"
"golang.org/x/text/language"
"testing"
)
func TestUserLocale_MarshalJSON(t *testing.T) {
assert.Equal(t, "\"en-US\"", encode(UserLocale{language.AmericanEnglish}))
assert.Equal(t, "\"en-GB\"", encode(UserLocale{language.BritishEnglish}))
}

View File

@ -0,0 +1,46 @@
package types
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"github.com/mrmelon54/pronouns"
)
var (
_ sql.Scanner = &UserPronoun{}
_ driver.Valuer = &UserPronoun{}
_ json.Marshaler = &UserPronoun{}
_ json.Unmarshaler = &UserPronoun{}
)
type UserPronoun struct{ pronouns.Pronoun }
func (p *UserPronoun) Scan(src any) error {
s, ok := src.(string)
if !ok {
return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type %T", src, p)
}
pro, err := pronouns.FindPronoun(s)
if err != nil {
return err
}
p.Pronoun = pro
return nil
}
func (p UserPronoun) Value() (driver.Value, error) {
return p.Pronoun.String(), nil
}
func (p UserPronoun) MarshalJSON() ([]byte, error) { return json.Marshal(p.Pronoun.String()) }
func (p *UserPronoun) UnmarshalJSON(bytes []byte) error {
var a string
err := json.Unmarshal(bytes, &a)
if err != nil {
return err
}
return p.Scan(a)
}

View File

@ -0,0 +1,15 @@
package types
import (
"github.com/mrmelon54/pronouns"
"github.com/stretchr/testify/assert"
"testing"
)
func TestUserPronoun_MarshalJSON(t *testing.T) {
assert.Equal(t, "\"they/them\"", encode(UserPronoun{pronouns.TheyThem}))
assert.Equal(t, "\"he/him\"", encode(UserPronoun{pronouns.HeHim}))
assert.Equal(t, "\"she/her\"", encode(UserPronoun{pronouns.SheHer}))
assert.Equal(t, "\"it/its\"", encode(UserPronoun{pronouns.ItIts}))
assert.Equal(t, "\"one/one's\"", encode(UserPronoun{pronouns.OneOnes}))
}

View File

@ -0,0 +1,27 @@
package types
import "fmt"
type UserRole int64
const (
RoleMember UserRole = iota
RoleAdmin
RoleToDelete
)
func (r UserRole) String() string {
switch r {
case RoleMember:
return "Member"
case RoleAdmin:
return "Admin"
case RoleToDelete:
return "ToDelete"
}
return fmt.Sprintf("UserRole{ %d }", r)
}
func (r UserRole) IsValid() bool {
return r == RoleMember || r == RoleAdmin
}

View File

@ -0,0 +1,48 @@
package types
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
var (
_ sql.Scanner = &UserZone{}
_ driver.Valuer = &UserZone{}
_ json.Marshaler = &UserZone{}
_ json.Unmarshaler = &UserZone{}
)
type UserZone struct{ *time.Location }
func (l *UserZone) Scan(src any) error {
s, ok := src.(string)
if !ok {
return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type %T", src, l)
}
loc, err := time.LoadLocation(s)
if err != nil {
return err
}
l.Location = loc
return nil
}
func (l UserZone) Value() (driver.Value, error) {
return l.Location.String(), nil
}
func (l UserZone) MarshalJSON() ([]byte, error) {
return json.Marshal(l.Location.String())
}
func (l *UserZone) UnmarshalJSON(bytes []byte) error {
var a string
err := json.Unmarshal(bytes, &a)
if err != nil {
return err
}
return l.Scan(a)
}

View File

@ -0,0 +1,14 @@
package types
import (
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestUserZone_MarshalJSON(t *testing.T) {
location, err := time.LoadLocation("Europe/London")
assert.NoError(t, err)
assert.Equal(t, "\"Europe/London\"", encode(UserZone{location}))
assert.Equal(t, "\"UTC\"", encode(UserZone{time.UTC}))
}

View File

@ -0,0 +1,11 @@
package types
import "encoding/json"
func encode(data any) string {
j, err := json.Marshal(data)
if err != nil {
panic(err)
}
return string(j)
}

1
go.mod
View File

@ -19,6 +19,7 @@ require (
github.com/google/uuid v1.6.0
github.com/julienschmidt/httprouter v1.3.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/mrmelon54/pronouns v1.0.3
github.com/spf13/afero v1.11.0
github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.26.0

2
go.sum
View File

@ -120,6 +120,8 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/mrmelon54/pronouns v1.0.3 h1:VJqOnNxIw44q0dRJrBEvOCkKPYGvPYcNRKwPtLildXg=
github.com/mrmelon54/pronouns v1.0.3/go.mod h1:VF6iGNf72tIokVE78GasPXvxlFUwGib7QFZOfpDNn18=
github.com/mrmelon54/rescheduler v0.0.3 h1:TrkJL6S7PKvXuo1mvdgRgsILA/pk5L1lrXhV/q7IEzQ=
github.com/mrmelon54/rescheduler v0.0.3/go.mod h1:q415n6W1xcePPP5Rix6FOiADgcN66BYMyNOsFnNyoWQ=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=

View File

@ -12,20 +12,20 @@ type Manager struct {
m map[string]*WellKnownOIDC
}
func NewManager(services []SsoConfig) (*Manager, error) {
func NewManager(services map[string]SsoConfig) (*Manager, error) {
l := &Manager{m: make(map[string]*WellKnownOIDC)}
for _, i := range services {
if !isValidNamespace.MatchString(i.Namespace) {
return nil, fmt.Errorf("invalid namespace: %s", i.Namespace)
for namespace, ssoService := range services {
if !isValidNamespace.MatchString(namespace) {
return nil, fmt.Errorf("invalid namespace: %s", namespace)
}
conf, err := i.FetchConfig()
conf, err := ssoService.FetchConfig()
if err != nil {
return nil, err
}
// save by namespace
l.m[i.Namespace] = conf
l.m[namespace] = conf
}
return l, nil
}

View File

@ -17,9 +17,8 @@ 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
Namespace string `json:"namespace"` // example.com
Client SsoConfigClient `json:"client"`
Addr utils.JsonUrl `json:"addr"` // https://login.example.com
Client SsoConfigClient `json:"client"`
}
type SsoConfigClient struct {

View File

@ -8,3 +8,16 @@ sql:
package: "database"
out: "database"
emit_json_tags: true
overrides:
- column: "users.password"
go_type: "github.com/1f349/tulip/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"
- column: "users.pronouns"
go_type: "github.com/1f349/tulip/database/types.UserPronoun"
- column: "users.zoneinfo"
go_type: "github.com/1f349/tulip/database/types.UserZone"
- column: "users.locale"
go_type: "github.com/1f349/tulip/database/types.UserLocale"