mirror of
https://github.com/1f349/tulip.git
synced 2024-11-12 14:51:32 +00:00
Add mailer and output true user info
This commit is contained in:
parent
fff03ac6ad
commit
e8bbb481fc
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,4 @@
|
||||
*.sqlite
|
||||
*.local
|
||||
.data
|
||||
.data/
|
||||
.idea/
|
||||
|
@ -1,8 +1,11 @@
|
||||
package main
|
||||
|
||||
import "github.com/1f349/tulip/mail"
|
||||
|
||||
type startUpConfig struct {
|
||||
Listen string `json:"listen"`
|
||||
Domain string `json:"domain"`
|
||||
OtpIssuer string `json:"otp_issuer"`
|
||||
ServiceName string `json:"service_name"`
|
||||
Listen string `json:"listen"`
|
||||
BaseUrl string `json:"base_url"`
|
||||
OtpIssuer string `json:"otp_issuer"`
|
||||
ServiceName string `json:"service_name"`
|
||||
Mail mail.Mail `json:"mail"`
|
||||
}
|
||||
|
@ -82,7 +82,7 @@ func normalLoad(startUp startUpConfig, wd string) {
|
||||
log.Fatal("[Tulip] Failed check:", err)
|
||||
}
|
||||
|
||||
srv := server.NewHttpServer(startUp.Listen, startUp.Domain, startUp.OtpIssuer, startUp.ServiceName, db, key)
|
||||
srv := server.NewHttpServer(startUp.Listen, startUp.BaseUrl, startUp.OtpIssuer, startUp.ServiceName, startUp.Mail, db, key)
|
||||
log.Printf("[Tulip] Starting HTTP server on '%s'\n", srv.Addr)
|
||||
go utils.RunBackgroundHttp("HTTP", srv)
|
||||
|
||||
@ -113,7 +113,7 @@ func checkDbHasUser(db *database.DB) error {
|
||||
defer tx.Rollback()
|
||||
if err := tx.HasUser(); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err := tx.InsertUser("Admin", "admin", "admin", "admin@localhost", database.RoleAdmin, false)
|
||||
_, err := tx.InsertUser("Admin", "admin", "admin", "admin@localhost", database.RoleAdmin, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add user: %w", err)
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/MrMelon54/pronouns"
|
||||
"github.com/go-oauth2/oauth2/v4"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/text/language"
|
||||
"net/url"
|
||||
@ -33,6 +34,7 @@ type UserRole int
|
||||
const (
|
||||
RoleMember UserRole = iota
|
||||
RoleAdmin
|
||||
RoleToDelete
|
||||
)
|
||||
|
||||
func (r UserRole) String() string {
|
||||
@ -41,6 +43,8 @@ func (r UserRole) String() string {
|
||||
return "Member"
|
||||
case RoleAdmin:
|
||||
return "Admin"
|
||||
case RoleToDelete:
|
||||
return "ToDelete"
|
||||
}
|
||||
return fmt.Sprintf("UserRole{ %d }", r)
|
||||
}
|
||||
@ -99,3 +103,22 @@ func (u *UserPatch) ParseFromForm(v url.Values) (safeErrs []error) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var _ oauth2.ClientInfo = &ClientInfoDbOutput{}
|
||||
|
||||
func (c *ClientInfoDbOutput) GetID() string { return c.Sub }
|
||||
func (c *ClientInfoDbOutput) GetSecret() string { return c.Secret }
|
||||
func (c *ClientInfoDbOutput) GetDomain() string { return c.Domain }
|
||||
func (c *ClientInfoDbOutput) IsPublic() bool { return false }
|
||||
func (c *ClientInfoDbOutput) GetUserID() string { return c.Owner }
|
||||
|
||||
// GetName is an extra field for the oauth handler to display the application
|
||||
// name
|
||||
func (c *ClientInfoDbOutput) GetName() string { return c.Name }
|
||||
|
||||
// IsSSO is an extra field for the oauth handler to skip the user input stage
|
||||
// this is for trusted applications to get permissions without asking the user
|
||||
func (c *ClientInfoDbOutput) IsSSO() bool { return c.SSO }
|
||||
|
||||
// IsActive is an extra field for the app manager to get the active state
|
||||
func (c *ClientInfoDbOutput) IsActive() bool { return c.Active }
|
||||
|
@ -37,25 +37,26 @@ func (t *Tx) HasUser() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Tx) InsertUser(name, un, pw, email string, role UserRole, active bool) error {
|
||||
func (t *Tx) InsertUser(name, un, pw, email string, role UserRole, active bool) (uuid.UUID, error) {
|
||||
pwHash, err := password.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
_, err = t.tx.Exec(`INSERT INTO users (subject, name, username, password, email, role, updated_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, uuid.NewString(), name, un, pwHash, email, role, updatedAt(), active)
|
||||
return err
|
||||
u := uuid.New()
|
||||
_, err = t.tx.Exec(`INSERT INTO users (subject, name, username, password, email, role, updated_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, u, name, un, pwHash, email, role, updatedAt(), active)
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (t *Tx) CheckLogin(un, pw string) (*User, bool, error) {
|
||||
func (t *Tx) CheckLogin(un, pw string) (*User, bool, bool, error) {
|
||||
var u User
|
||||
var hasOtp bool
|
||||
row := t.tx.QueryRow(`SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject) FROM users WHERE username = ?`, un)
|
||||
err := row.Scan(&u.Sub, &u.Password, &hasOtp)
|
||||
var hasOtp, hasVerify bool
|
||||
row := t.tx.QueryRow(`SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject), email, email_verified FROM users WHERE username = ?`, un)
|
||||
err := row.Scan(&u.Sub, &u.Password, &hasOtp, &u.Email, &hasVerify)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
return nil, false, false, err
|
||||
}
|
||||
err = password.CheckPasswordHash(u.Password, pw)
|
||||
return &u, hasOtp, err
|
||||
return &u, hasOtp, hasVerify, err
|
||||
}
|
||||
|
||||
func (t *Tx) GetUserDisplayName(sub uuid.UUID) (*User, error) {
|
||||
@ -269,26 +270,12 @@ func (t *Tx) UpdateUser(subject uuid.UUID, role UserRole, active bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Tx) VerifyUserEmail(sub uuid.UUID) error {
|
||||
_, err := t.tx.Exec(`UPDATE users SET email_verified = 1 WHERE subject = ?`, sub.String())
|
||||
return err
|
||||
}
|
||||
|
||||
type ClientInfoDbOutput struct {
|
||||
Sub, Name, Secret, Domain, Owner string
|
||||
SSO, Active bool
|
||||
}
|
||||
|
||||
var _ oauth2.ClientInfo = &ClientInfoDbOutput{}
|
||||
|
||||
func (c *ClientInfoDbOutput) GetID() string { return c.Sub }
|
||||
func (c *ClientInfoDbOutput) GetSecret() string { return c.Secret }
|
||||
func (c *ClientInfoDbOutput) GetDomain() string { return c.Domain }
|
||||
func (c *ClientInfoDbOutput) IsPublic() bool { return false }
|
||||
func (c *ClientInfoDbOutput) GetUserID() string { return c.Owner }
|
||||
|
||||
// GetName is an extra field for the oauth handler to display the application
|
||||
// name
|
||||
func (c *ClientInfoDbOutput) GetName() string { return c.Name }
|
||||
|
||||
// IsSSO is an extra field for the oauth handler to skip the user input stage
|
||||
// this is for trusted applications to get permissions without asking the user
|
||||
func (c *ClientInfoDbOutput) IsSSO() bool { return c.SSO }
|
||||
|
||||
// IsActive is an extra field for the app manager to get the active state
|
||||
func (c *ClientInfoDbOutput) IsActive() bool { return c.Active }
|
||||
|
4
go.mod
4
go.mod
@ -1,8 +1,9 @@
|
||||
module github.com/1f349/tulip
|
||||
|
||||
go 1.20
|
||||
go 1.21.1
|
||||
|
||||
require (
|
||||
github.com/1f349/cache v0.0.2
|
||||
github.com/1f349/twofactor v1.0.4
|
||||
github.com/1f349/violet v0.0.9
|
||||
github.com/MrMelon54/exit-reload v0.0.1
|
||||
@ -22,6 +23,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/MrMelon54/rescheduler v0.0.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
|
10
go.sum
10
go.sum
@ -1,4 +1,8 @@
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/1f349/cache v0.0.1 h1:Aa2TmfewUDPCLLKmV/gSt0H6Wr2bcKu08zD5m+Im0K4=
|
||||
github.com/1f349/cache v0.0.1/go.mod h1:LibAMy13dF0KO1fQA9aEjZPBCB6Y4b5kKYEQJUqc2rQ=
|
||||
github.com/1f349/cache v0.0.2 h1:27QD6zPd9xYyvh9V1qqWq+EAt5+N+qvyGWKfnjMrhP8=
|
||||
github.com/1f349/cache v0.0.2/go.mod h1:LibAMy13dF0KO1fQA9aEjZPBCB6Y4b5kKYEQJUqc2rQ=
|
||||
github.com/1f349/twofactor v1.0.4 h1:kN4EEGFlKRa7fGrxS+FpgwJI+tllES6YzXqCqurk4Uk=
|
||||
github.com/1f349/twofactor v1.0.4/go.mod h1:gnG80vElwqLWNMnLT57yu4o4L1GdXGPP6pcIPlapXZs=
|
||||
github.com/1f349/violet v0.0.9 h1:eQfc5fDMKJXVFUjS2UiAGTkOVVBamppD5dguhmU4GeU=
|
||||
@ -7,6 +11,8 @@ github.com/MrMelon54/exit-reload v0.0.1 h1:sxHa59tNEQMcikwuX2+93lw6Vi1+R7oCRF8a0
|
||||
github.com/MrMelon54/exit-reload v0.0.1/go.mod h1:PLiSfmUzwdpTTQP3BBfUPhkqPwaIZjx0DuXBnM76Bug=
|
||||
github.com/MrMelon54/pronouns v1.0.1 h1:JOEA5Z1pEkNRTzs314quIDC0JW7vUWs4CT3wGtNMzR0=
|
||||
github.com/MrMelon54/pronouns v1.0.1/go.mod h1:Yv1qwDRk0dYwx29az0nSNfPzvuSe5sDrAWc7GoMp/3k=
|
||||
github.com/MrMelon54/rescheduler v0.0.2 h1:efrRwr0BYlkaXFucZDjQqRyIawZiMEAnzjea46Bs9Oc=
|
||||
github.com/MrMelon54/rescheduler v0.0.2/go.mod h1:OQDFtZHdS4/qA/r7rtJUQA22/hbpnZ9MGQCXOPjhC6w=
|
||||
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||
@ -59,6 +65,7 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
|
||||
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
|
||||
@ -108,6 +115,7 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI=
|
||||
github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8=
|
||||
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
|
||||
github.com/tidwall/btree v1.6.0 h1:LDZfKfQIBHGHWSwckhXI0RPSXzlo+KYdjK7FWSqOzzg=
|
||||
github.com/tidwall/btree v1.6.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY=
|
||||
@ -122,6 +130,7 @@ github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9O
|
||||
github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
|
||||
github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
|
||||
github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
|
||||
github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8=
|
||||
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
@ -174,6 +183,7 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
26
mail/from-address.go
Normal file
26
mail/from-address.go
Normal file
@ -0,0 +1,26 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
type FromAddress struct {
|
||||
*mail.Address
|
||||
}
|
||||
|
||||
var _ json.Unmarshaler = &FromAddress{}
|
||||
|
||||
func (f *FromAddress) UnmarshalJSON(b []byte) error {
|
||||
var a string
|
||||
err := json.Unmarshal(b, &a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
address, err := mail.ParseAddress(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.Address = address
|
||||
return nil
|
||||
}
|
85
mail/mail.go
Normal file
85
mail/mail.go
Normal file
@ -0,0 +1,85 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/emersion/go-message"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/emersion/go-smtp"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Mail struct {
|
||||
Name string `json:"name"`
|
||||
Tls bool `json:"tls"`
|
||||
Server string `json:"server"`
|
||||
From FromAddress `json:"from"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (m *Mail) loginInfo() sasl.Client {
|
||||
return sasl.NewPlainClient("", m.Username, m.Password)
|
||||
}
|
||||
|
||||
func (m *Mail) mailCall(to []string, r io.Reader) error {
|
||||
host, _, err := net.SplitHostPort(m.Server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Tls {
|
||||
return smtp.SendMailTLS(m.Server, m.loginInfo(), m.From.String(), to, r)
|
||||
}
|
||||
if host == "localhost" || host == "127.0.0.1" {
|
||||
// internals of smtp.SendMail without STARTTLS for localhost testing
|
||||
dial, err := smtp.Dial(m.Server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = dial.Auth(m.loginInfo())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dial.SendMail(m.From.String(), to, r)
|
||||
}
|
||||
return smtp.SendMail(m.Server, m.loginInfo(), m.From.String(), to, r)
|
||||
}
|
||||
|
||||
func (m *Mail) genHeaders(subject string, to []*mail.Address, htmlBody bool) mail.Header {
|
||||
var h mail.Header
|
||||
h.SetDate(time.Now())
|
||||
h.SetSubject(subject)
|
||||
h.SetAddressList("From", []*mail.Address{m.From.Address})
|
||||
h.SetAddressList("To", to)
|
||||
|
||||
if htmlBody {
|
||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||
} else {
|
||||
h.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Mail) SendMail(subject string, to []*mail.Address, htmlBody bool, body io.Reader) error {
|
||||
// generate the email in this template
|
||||
buf := new(bytes.Buffer)
|
||||
h := m.genHeaders(subject, to, htmlBody)
|
||||
entity, err := message.New(h.Header, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = entity.WriteTo(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// convert all to addresses to strings
|
||||
toStr := make([]string, len(to))
|
||||
for i := range toStr {
|
||||
toStr[i] = to[i].String()
|
||||
}
|
||||
|
||||
return m.mailCall(toStr, buf)
|
||||
}
|
18
mail/send-template.go
Normal file
18
mail/send-template.go
Normal file
@ -0,0 +1,18 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/1f349/tulip/mail/templates"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
func (m *Mail) SendEmailTemplate(templateName, subject, nameOfUser string, to *mail.Address, data map[string]any) error {
|
||||
buf := new(bytes.Buffer)
|
||||
templates.RenderMailTemplate(buf, templateName, map[string]any{
|
||||
"ServiceName": m.Name,
|
||||
"Name": nameOfUser,
|
||||
"Data": data,
|
||||
})
|
||||
return m.SendMail(fmt.Sprintf("%s - %s", subject, m.Name), []*mail.Address{to}, false, buf)
|
||||
}
|
10
mail/templates/mail-account-delete.go.txt
Normal file
10
mail/templates/mail-account-delete.go.txt
Normal file
@ -0,0 +1,10 @@
|
||||
Hello, {{.Name}}
|
||||
|
||||
Your account with {{.ServiceName}} has been disabled and marked for deletion.
|
||||
|
||||
Your account will be fully deleted within 48-hours.
|
||||
|
||||
You will no longer receive emails from {{.ServiceName}}, unless your email address is used to set up an account.
|
||||
|
||||
Regards,
|
||||
{{.ServiceName}}
|
10
mail/templates/mail-register-delete.go.txt
Normal file
10
mail/templates/mail-register-delete.go.txt
Normal file
@ -0,0 +1,10 @@
|
||||
Hello, {{.Name}}
|
||||
|
||||
Your email address has been registered with {{.ServiceName}}.
|
||||
|
||||
To log into your account please use this link to reset your password: {{.Data.ResetUrl}}
|
||||
|
||||
If you did not wish to register for {{.ServiceName}}, please fill out this account deletion form: {{.Data.DeleteUrl}}
|
||||
|
||||
Regards,
|
||||
{{.ServiceName}}
|
8
mail/templates/mail-reset-password.go.txt
Normal file
8
mail/templates/mail-reset-password.go.txt
Normal file
@ -0,0 +1,8 @@
|
||||
Hello, {{.Name}}
|
||||
|
||||
Please open this link to reset your password: {{.Data.ResetUrl}}
|
||||
|
||||
This link is valid for 10 minutes.
|
||||
|
||||
Regards,
|
||||
{{.ServiceName}}
|
8
mail/templates/mail-verify.go.txt
Normal file
8
mail/templates/mail-verify.go.txt
Normal file
@ -0,0 +1,8 @@
|
||||
Hello, {{.Name}}
|
||||
|
||||
Please open this link to verify your email address: {{.Data.VerifyUrl}}
|
||||
|
||||
This link is valid for 10 minutes.
|
||||
|
||||
Regards,
|
||||
{{.ServiceName}}
|
27
mail/templates/templates.go
Normal file
27
mail/templates/templates.go
Normal file
@ -0,0 +1,27 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"log"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed *
|
||||
embeddedTemplates embed.FS
|
||||
|
||||
mailTemplate *template.Template
|
||||
)
|
||||
|
||||
func LoadMailTemplates() (err error) {
|
||||
mailTemplate, err = template.New("mail").ParseFS(embeddedTemplates, "*.go.txt")
|
||||
return
|
||||
}
|
||||
|
||||
func RenderMailTemplate(wr io.Writer, name string, data any) {
|
||||
err := mailTemplate.ExecuteTemplate(wr, name+".go.txt", data)
|
||||
if err != nil {
|
||||
log.Printf("Failed to render mail: %s: %s\n", name, err)
|
||||
}
|
||||
}
|
@ -1,5 +1,9 @@
|
||||
package openid
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
@ -11,12 +15,17 @@ type Config struct {
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
}
|
||||
|
||||
func GenConfig(domain string, scopes, claims []string) Config {
|
||||
func GenConfig(baseUrl string, scopes, claims []string) Config {
|
||||
baseUrlRaw := baseUrl
|
||||
if !strings.HasSuffix(baseUrl, "/") {
|
||||
baseUrl += "/"
|
||||
}
|
||||
|
||||
return Config{
|
||||
Issuer: "https://" + domain,
|
||||
AuthorizationEndpoint: "https://" + domain + "/authorize",
|
||||
TokenEndpoint: "https://" + domain + "/token",
|
||||
UserInfoEndpoint: "https://" + domain + "/userinfo",
|
||||
Issuer: baseUrlRaw,
|
||||
AuthorizationEndpoint: baseUrl + "authorize",
|
||||
TokenEndpoint: baseUrl + "token",
|
||||
UserInfoEndpoint: baseUrl + "userinfo",
|
||||
ResponseTypesSupported: []string{"code"},
|
||||
ScopesSupported: scopes,
|
||||
ClaimsSupported: claims,
|
||||
|
@ -15,5 +15,5 @@ func TestGenConfig(t *testing.T) {
|
||||
ScopesSupported: []string{"openid", "email"},
|
||||
ClaimsSupported: []string{"name", "email", "preferred_username"},
|
||||
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
|
||||
}, GenConfig("example.com", []string{"openid", "email"}, []string{"name", "email", "preferred_username"}))
|
||||
}, GenConfig("https://example.com", []string{"openid", "email"}, []string{"name", "email", "preferred_username"}))
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
package pages
|
@ -1 +0,0 @@
|
||||
package pages
|
@ -8,6 +8,12 @@
|
||||
<h1>{{.ServiceName}}</h1>
|
||||
</header>
|
||||
<main>
|
||||
{{if eq .Mismatch "1"}}
|
||||
<p>Invalid username or password</p>
|
||||
{{end}}
|
||||
{{if eq .Mismatch "2"}}
|
||||
<p>Check your inbox for a verification email</p>
|
||||
{{end}}
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="redirect" value="{{.Redirect}}"/>
|
||||
<div>
|
||||
|
@ -1,11 +1,27 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrInvalidScope = errors.New("Invalid scope")
|
||||
|
||||
var scopeDescription = map[string]string{
|
||||
"openid": "Access user identity and information fields",
|
||||
"openid": "Verify your user identity",
|
||||
"name": "Access your name",
|
||||
"username": "Access your username",
|
||||
"profile": "Access your public profile",
|
||||
"email": "Access your email",
|
||||
"birthdate": "Access your birthdate",
|
||||
"age": "Access your current age",
|
||||
"zoneinfo": "Access time zone setting",
|
||||
"locale": "Access your language setting",
|
||||
}
|
||||
|
||||
func ScopesExist(scope string) bool {
|
||||
_, err := internalGetScopes(scope, func(key, desc string) string { return "" })
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// FancyScopeList takes a scope string and outputs a slice of scope descriptions
|
||||
@ -49,3 +65,47 @@ outer:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func internalGetScopes(scope string, f func(key, desc string) string) (arr []string, err error) {
|
||||
seen := make(map[string]struct{})
|
||||
outer:
|
||||
for {
|
||||
n := strings.IndexAny(scope, ", ")
|
||||
var key string
|
||||
switch n {
|
||||
case 0:
|
||||
// first char is matching, no key name found, just continue
|
||||
scope = scope[1:]
|
||||
continue outer
|
||||
case -1:
|
||||
// no more matching chars, if scope is empty then we are done
|
||||
if len(scope) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// otherwise set the key and empty scope
|
||||
key = scope
|
||||
scope = ""
|
||||
default:
|
||||
// set the key and trim from scope
|
||||
key = scope[:n]
|
||||
scope = scope[n+1:]
|
||||
}
|
||||
|
||||
// check if key has been seen already
|
||||
if _, ok := seen[key]; ok {
|
||||
continue outer
|
||||
}
|
||||
|
||||
// set seen flag
|
||||
seen[key] = struct{}{}
|
||||
|
||||
// output the description
|
||||
if d, ok := scopeDescription[key]; ok && d != "" {
|
||||
arr = append(arr, f(key, d))
|
||||
continue
|
||||
}
|
||||
|
||||
err = ErrInvalidScope
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth)
|
||||
@ -105,7 +106,21 @@ func internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (U
|
||||
}
|
||||
|
||||
func PrepareRedirectUrl(targetPath string, origin *url.URL) *url.URL {
|
||||
// find start of query parameters in target path
|
||||
n := strings.IndexByte(targetPath, '?')
|
||||
v := url.Values{}
|
||||
|
||||
// parse existing query parameters
|
||||
if n != -1 {
|
||||
q, err := url.ParseQuery(targetPath[n+1:])
|
||||
if err != nil {
|
||||
panic("PrepareRedirectUrl: invalid hardcoded target path query parameters")
|
||||
}
|
||||
v = q
|
||||
targetPath = targetPath[:n]
|
||||
}
|
||||
|
||||
// add path of origin as a new query parameter
|
||||
orig := origin.Path
|
||||
if origin.RawQuery != "" || origin.ForceQuery {
|
||||
orig += "?" + origin.RawQuery
|
||||
|
@ -114,4 +114,10 @@ func TestPrepareRedirectUrl(t *testing.T) {
|
||||
assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *PrepareRedirectUrl("/a", &url.URL{Path: "/hello"}))
|
||||
assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *PrepareRedirectUrl("/a", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()}))
|
||||
assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello?a=A&b=B"}}.Encode()}, *PrepareRedirectUrl("/a", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}, "b": {"B"}}.Encode()}))
|
||||
|
||||
assert.Equal(t, url.URL{Path: "/hello", RawQuery: "z=y"}, *PrepareRedirectUrl("/hello?z=y", &url.URL{}))
|
||||
assert.Equal(t, url.URL{Path: "/world", RawQuery: "z=y"}, *PrepareRedirectUrl("/world?z=y", &url.URL{}))
|
||||
assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello"}}.Encode()}, *PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello"}))
|
||||
assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello?a=A"}}.Encode()}, *PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()}))
|
||||
assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello?a=A&b=B"}}.Encode()}, *PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}, "b": {"B"}}.Encode()}))
|
||||
}
|
||||
|
@ -3,13 +3,17 @@ package server
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/pages"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (h *HttpServer) LoginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
|
||||
@ -23,34 +27,80 @@ func (h *HttpServer) LoginGet(rw http.ResponseWriter, req *http.Request, _ httpr
|
||||
pages.RenderPageTemplate(rw, "login", map[string]any{
|
||||
"ServiceName": h.serviceName,
|
||||
"Redirect": req.URL.Query().Get("redirect"),
|
||||
"Mismatch": req.URL.Query().Get("mismatch"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
|
||||
un := req.FormValue("username")
|
||||
pw := req.FormValue("password")
|
||||
var userSub uuid.UUID
|
||||
|
||||
// flags returned from database call
|
||||
var userInfo *database.User
|
||||
var loginMismatch byte
|
||||
var hasOtp bool
|
||||
|
||||
if h.DbTx(rw, func(tx *database.Tx) error {
|
||||
loginUser, hasOtpRaw, err := tx.CheckLogin(un, pw)
|
||||
loginUser, hasOtpRaw, hasVerifiedEmail, err := tx.CheckLogin(un, pw)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
http.Redirect(rw, req, "/login?mismatch=1", http.StatusFound)
|
||||
loginMismatch = 1
|
||||
return nil
|
||||
}
|
||||
http.Error(rw, "Internal server error", http.StatusInternalServerError)
|
||||
return err
|
||||
}
|
||||
userSub = loginUser.Sub
|
||||
|
||||
userInfo = loginUser
|
||||
hasOtp = hasOtpRaw
|
||||
if !hasVerifiedEmail {
|
||||
loginMismatch = 2
|
||||
}
|
||||
return nil
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
if loginMismatch != 0 {
|
||||
originUrl, err := url.Parse(req.FormValue("redirect"))
|
||||
if err != nil {
|
||||
http.Error(rw, "400 Bad Request: Invalid redirect URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// send verify email
|
||||
if loginMismatch == 2 {
|
||||
// parse email for headers
|
||||
address, err := mail.ParseAddress(userInfo.Email)
|
||||
if err != nil {
|
||||
http.Error(rw, "500 Internal Server Error: Failed to parse user email address", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
u := uuid.New()
|
||||
h.mailLinkCache.Set(mailLinkKey{mailLinkVerifyEmail, u}, userInfo.Sub, time.Now().Add(10*time.Minute))
|
||||
|
||||
// try to send email
|
||||
err = h.mailer.SendEmailTemplate("mail-verify", "Verify Email", userInfo.Name, address, map[string]any{
|
||||
"VerifyUrl": h.domain + "/mail/verify/" + u.String(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("[Tulip] Login: Failed to send verification email:", err)
|
||||
http.Error(rw, "500 Internal Server Error: Failed to send verification email", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// send email successfully, hope the user actually receives it
|
||||
}
|
||||
|
||||
redirectUrl := PrepareRedirectUrl(fmt.Sprintf("/login?mismatch=%d", loginMismatch), originUrl)
|
||||
http.Redirect(rw, req, redirectUrl.String(), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// only continues if the above tx succeeds
|
||||
auth.Data = SessionData{
|
||||
ID: userSub,
|
||||
ID: userInfo.Sub,
|
||||
NeedOtp: hasOtp,
|
||||
}
|
||||
if auth.SaveSessionData() != nil {
|
||||
|
83
server/mail.go
Normal file
83
server/mail.go
Normal file
@ -0,0 +1,83 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *HttpServer) MailVerify(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
code := params.ByName("code")
|
||||
parse, err := uuid.Parse(code)
|
||||
if err != nil {
|
||||
http.Error(rw, "Invalid email verification code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
k := mailLinkKey{mailLinkVerifyEmail, parse}
|
||||
|
||||
userSub, ok := h.mailLinkCache.Get(k)
|
||||
if !ok {
|
||||
http.Error(rw, "Invalid email verification code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if h.DbTx(rw, func(tx *database.Tx) error {
|
||||
return tx.VerifyUserEmail(userSub)
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
h.mailLinkCache.Delete(k)
|
||||
|
||||
http.Error(rw, "Email address has been verified, you may close this tab and return to the login page.", http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *HttpServer) MailPassword(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
http.Error(rw, "Reset password is not functional yet", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (h *HttpServer) MailDelete(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
code := params.ByName("code")
|
||||
parse, err := uuid.Parse(code)
|
||||
if err != nil {
|
||||
http.Error(rw, "Invalid email delete code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
k := mailLinkKey{mailLinkDelete, parse}
|
||||
|
||||
userSub, ok := h.mailLinkCache.Get(k)
|
||||
if !ok {
|
||||
http.Error(rw, "Invalid email delete code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var userInfo *database.User
|
||||
if h.DbTx(rw, func(tx *database.Tx) (err error) {
|
||||
userInfo, err = tx.GetUser(userSub)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return tx.UpdateUser(userSub, database.RoleToDelete, false)
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
h.mailLinkCache.Delete(k)
|
||||
|
||||
// parse email for headers
|
||||
address, err := mail.ParseAddress(userInfo.Email)
|
||||
if err != nil {
|
||||
http.Error(rw, "500 Internal Server Error: Failed to parse user email address", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.mailer.SendEmailTemplate("mail-account-delete", "Account Deletion", userInfo.Name, address, nil)
|
||||
if err != nil {
|
||||
http.Error(rw, "Failed to send confirmation email.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(rw, "You will receive an email shortly to verify this action, you may close this tab.", http.StatusOK)
|
||||
}
|
@ -4,11 +4,14 @@ import (
|
||||
"errors"
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/pages"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
|
||||
@ -97,11 +100,34 @@ func (h *HttpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request,
|
||||
|
||||
switch action {
|
||||
case "create":
|
||||
if h.DbTx(rw, func(tx *database.Tx) error {
|
||||
return tx.InsertUser(name, username, "", email, newRole, active)
|
||||
var userSub uuid.UUID
|
||||
if h.DbTx(rw, func(tx *database.Tx) (err error) {
|
||||
userSub, err = tx.InsertUser(name, username, "", email, newRole, active)
|
||||
return err
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
// parse email for headers
|
||||
address, err := mail.ParseAddress(email)
|
||||
if err != nil {
|
||||
http.Error(rw, "500 Internal Server Error: Failed to parse user email address", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
u, u2 := uuid.New(), uuid.New()
|
||||
h.mailLinkCache.Set(mailLinkKey{mailLinkResetPassword, u}, userSub, time.Now().Add(10*time.Minute))
|
||||
h.mailLinkCache.Set(mailLinkKey{mailLinkDelete, u2}, userSub, time.Now().Add(10*time.Minute))
|
||||
|
||||
err = h.mailer.SendEmailTemplate("mail-register-delete", "Register", name, address, map[string]any{
|
||||
"ResetUrl": h.domain + "/mail/password/" + u.String(),
|
||||
"DeleteUrl": h.domain + "/mail/delete/" + u2.String(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("[Tulip] Login: Failed to send register email:", err)
|
||||
http.Error(rw, "500 Internal Server Error: Failed to send register email", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case "edit":
|
||||
if h.DbTx(rw, func(tx *database.Tx) error {
|
||||
sub, err := uuid.Parse(req.Form.Get("subject"))
|
||||
|
159
server/server.go
159
server/server.go
@ -5,23 +5,29 @@ import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/1f349/cache"
|
||||
clientStore "github.com/1f349/tulip/client-store"
|
||||
"github.com/1f349/tulip/database"
|
||||
"github.com/1f349/tulip/mail"
|
||||
"github.com/1f349/tulip/mail/templates"
|
||||
"github.com/1f349/tulip/openid"
|
||||
"github.com/1f349/tulip/pages"
|
||||
scope2 "github.com/1f349/tulip/scope"
|
||||
"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"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errMissingRequiredScope = errors.New("missing required scope")
|
||||
var errInvalidScope = errors.New("missing required scope")
|
||||
|
||||
type HttpServer struct {
|
||||
r *httprouter.Router
|
||||
@ -32,6 +38,21 @@ type HttpServer struct {
|
||||
privKey []byte
|
||||
otpIssuer string
|
||||
serviceName string
|
||||
mailer mail.Mail
|
||||
|
||||
// mailLinkCache contains a mapping of verify uuids to user uuids
|
||||
mailLinkCache *cache.Cache[mailLinkKey, uuid.UUID]
|
||||
}
|
||||
|
||||
const (
|
||||
mailLinkDelete byte = iota
|
||||
mailLinkResetPassword
|
||||
mailLinkVerifyEmail
|
||||
)
|
||||
|
||||
type mailLinkKey struct {
|
||||
action byte
|
||||
data uuid.UUID
|
||||
}
|
||||
|
||||
func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) {
|
||||
@ -52,10 +73,10 @@ func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(rw, req, parse.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
func NewHttpServer(listen, domain, otpIssuer, serviceName string, db *database.DB, privKey []byte) *http.Server {
|
||||
func NewHttpServer(listen, domain, otpIssuer, serviceName string, mailer mail.Mail, db *database.DB, privKey []byte) *http.Server {
|
||||
r := httprouter.New()
|
||||
|
||||
openIdConf := openid.GenConfig(domain, []string{"openid", "email"}, []string{"sub", "name", "preferred_username", "profile", "picture", "website", "email", "email_verified", "gender", "birthdate", "zoneinfo", "locale", "updated_at"})
|
||||
openIdConf := openid.GenConfig(domain, []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 {
|
||||
log.Fatalln("Failed to generate OpenID configuration:", err)
|
||||
@ -64,6 +85,9 @@ func NewHttpServer(listen, domain, otpIssuer, serviceName string, db *database.D
|
||||
if err := pages.LoadPageTemplates(); err != nil {
|
||||
log.Fatalln("Failed to load page templates:", err)
|
||||
}
|
||||
if err := templates.LoadMailTemplates(); err != nil {
|
||||
log.Fatalln("Failed to load mail templates:", err)
|
||||
}
|
||||
|
||||
oauthManager := manage.NewDefaultManager()
|
||||
oauthSrv := server.NewServer(server.NewConfig(), oauthManager)
|
||||
@ -76,6 +100,9 @@ func NewHttpServer(listen, domain, otpIssuer, serviceName string, db *database.D
|
||||
privKey: privKey,
|
||||
otpIssuer: otpIssuer,
|
||||
serviceName: serviceName,
|
||||
mailer: mailer,
|
||||
|
||||
mailLinkCache: cache.New[mailLinkKey, uuid.UUID](),
|
||||
}
|
||||
|
||||
oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
|
||||
@ -105,8 +132,8 @@ func NewHttpServer(listen, domain, otpIssuer, serviceName string, db *database.D
|
||||
form = req.URL.Query()
|
||||
}
|
||||
a := form.Get("scope")
|
||||
if a != "openid" {
|
||||
return "", errMissingRequiredScope
|
||||
if !scope2.ScopesExist(a) {
|
||||
return "", errInvalidScope
|
||||
}
|
||||
return "openid", nil
|
||||
})
|
||||
@ -140,6 +167,11 @@ func NewHttpServer(listen, domain, otpIssuer, serviceName string, db *database.D
|
||||
r.GET("/login/otp", OptionalAuthentication(true, hs.LoginOtpGet))
|
||||
r.POST("/login/otp", OptionalAuthentication(true, hs.LoginOtpPost))
|
||||
|
||||
// mail codes
|
||||
r.GET("/mail/verify/:code", hs.MailVerify)
|
||||
r.GET("/mail/password/:code", hs.MailPassword)
|
||||
r.GET("/mail/delete/:code", hs.MailDelete)
|
||||
|
||||
// edit profile pages
|
||||
r.GET("/edit", RequireAuthentication(hs.EditGet))
|
||||
r.POST("/edit", RequireAuthentication(hs.EditPost))
|
||||
@ -166,23 +198,62 @@ func NewHttpServer(listen, domain, otpIssuer, serviceName string, db *database.D
|
||||
http.Error(rw, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
fmt.Printf("Using token for user: %s by app: %s with scope: '%s'\n", token.GetUserID(), token.GetClientID(), token.GetScope())
|
||||
_ = json.NewEncoder(rw).Encode(map[string]any{
|
||||
"sub": token.GetUserID(),
|
||||
"aud": token.GetClientID(),
|
||||
"name": "Melon",
|
||||
"preferred_username": "melon",
|
||||
"profile": "https://" + domain + "/user/melon",
|
||||
"picture": "https://" + domain + "/picture/melon.svg",
|
||||
"website": "https://mrmelon54.com",
|
||||
"email": "melon@mrmelon54.com",
|
||||
"email_verified": true,
|
||||
"gender": "male",
|
||||
"birthdate": time.Now().Format(time.DateOnly),
|
||||
"zoneinfo": "Europe/London",
|
||||
"locale": "en-GB",
|
||||
"updated_at": time.Now().Unix(),
|
||||
})
|
||||
userId := token.GetUserID()
|
||||
userUuid, err := uuid.Parse(userId)
|
||||
if err != nil {
|
||||
http.Error(rw, "Invalid User ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Using token for user: %s by app: %s with scope: '%s'\n", userId, token.GetClientID(), token.GetScope())
|
||||
claims := ParseClaims(token.GetScope())
|
||||
if !claims["openid"] {
|
||||
http.Error(rw, "Invalid scope", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var userData *database.User
|
||||
|
||||
if hs.DbTx(rw, func(tx *database.Tx) (err error) {
|
||||
userData, err = tx.GetUser(userUuid)
|
||||
return err
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
m := map[string]any{}
|
||||
m["sub"] = userId
|
||||
m["aud"] = token.GetClientID()
|
||||
if claims["name"] {
|
||||
m["name"] = userData.Name
|
||||
}
|
||||
if claims["username"] {
|
||||
m["preferred_username"] = userData.Name
|
||||
}
|
||||
if claims["profile"] {
|
||||
m["profile"] = domain + "/user/" + userData.Username
|
||||
m["picture"] = userData.Picture.String()
|
||||
m["website"] = userData.Website.String()
|
||||
}
|
||||
if claims["email"] {
|
||||
m["email"] = userData.Email
|
||||
m["email_verified"] = userData.EmailVerified
|
||||
}
|
||||
if claims["birthdate"] {
|
||||
m["birthdate"] = userData.Birthdate.String()
|
||||
}
|
||||
if claims["age"] {
|
||||
m["age"] = CalculateAge(userData.Birthdate.Time.In(userData.ZoneInfo.Location))
|
||||
}
|
||||
if claims["zoneinfo"] {
|
||||
m["zoneinfo"] = userData.ZoneInfo.Location.String()
|
||||
}
|
||||
if claims["locale"] {
|
||||
m["locale"] = userData.Locale.Tag.String()
|
||||
}
|
||||
m["updated_at"] = time.Now().Unix()
|
||||
|
||||
_ = json.NewEncoder(rw).Encode(m)
|
||||
})
|
||||
|
||||
return &http.Server{
|
||||
@ -195,3 +266,47 @@ func NewHttpServer(listen, domain, otpIssuer, serviceName string, db *database.D
|
||||
MaxHeaderBytes: 2500,
|
||||
}
|
||||
}
|
||||
|
||||
func ParseClaims(claims string) map[string]bool {
|
||||
m := make(map[string]bool)
|
||||
for {
|
||||
n := strings.IndexByte(claims, ' ')
|
||||
if n == -1 {
|
||||
if claims != "" {
|
||||
m[claims] = true
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
a := claims[:n]
|
||||
claims = claims[n+1:]
|
||||
if a != "" {
|
||||
m[a] = true
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
var ageTimeNow = func() time.Time { return time.Now() }
|
||||
|
||||
func CalculateAge(t time.Time) int {
|
||||
n := ageTimeNow()
|
||||
|
||||
// the birthday is in the future so the age is 0
|
||||
if n.Before(t) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// the year difference
|
||||
dy := n.Year() - t.Year()
|
||||
|
||||
// the birthday in the current year
|
||||
tCurrent := t.AddDate(dy, 0, 0)
|
||||
|
||||
// minus 1 if the birthday has not yet occurred in the current year
|
||||
if tCurrent.Before(n) {
|
||||
dy -= 1
|
||||
}
|
||||
return dy
|
||||
}
|
||||
|
38
server/server_test.go
Normal file
38
server/server_test.go
Normal file
@ -0,0 +1,38 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseClaims(t *testing.T) {
|
||||
assert.Equal(t, map[string]bool{"openid": true, "email": true}, ParseClaims("openid email"))
|
||||
assert.Equal(t, map[string]bool{"openid": true, "profile": true, "email": true}, ParseClaims("openid profile email"))
|
||||
assert.Equal(t, map[string]bool{"openid": true, "email": true}, ParseClaims("openid email "))
|
||||
assert.Equal(t, map[string]bool{"openid": true, "email": true}, ParseClaims(" openid email"))
|
||||
assert.Equal(t, map[string]bool{"openid": true, "profile": true, "email": true}, ParseClaims(" openid profile email "))
|
||||
}
|
||||
|
||||
func TestCalculateAge(t *testing.T) {
|
||||
lGmt := time.FixedZone("GMT", 0)
|
||||
lBst := time.FixedZone("BST", 60*60)
|
||||
|
||||
tPast := time.Date(1939, time.January, 5, 0, 0, 0, 0, lGmt)
|
||||
tPastDst := time.Date(2001, time.January, 5, 1, 0, 0, 0, lBst)
|
||||
tCur := time.Date(2005, time.January, 5, 0, 30, 0, 0, lGmt)
|
||||
tCurDst := time.Date(2005, time.January, 5, 0, 30, 0, 0, lBst)
|
||||
tFut := time.Date(2008, time.January, 5, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
ageTimeNow = func() time.Time { return tCur }
|
||||
assert.Equal(t, 65, CalculateAge(tPast))
|
||||
assert.Equal(t, 3, CalculateAge(tPastDst))
|
||||
assert.Equal(t, 0, CalculateAge(tFut))
|
||||
|
||||
ageTimeNow = func() time.Time { return tCurDst }
|
||||
assert.Equal(t, 66, CalculateAge(tPast))
|
||||
assert.Equal(t, 4, CalculateAge(tPastDst))
|
||||
fmt.Println(tPastDst.AddDate(4, 0, 0).UTC(), tCur.UTC())
|
||||
assert.Equal(t, 0, CalculateAge(tFut))
|
||||
}
|
Loading…
Reference in New Issue
Block a user