Add mailer and output true user info

This commit is contained in:
Melon 2023-09-24 18:24:16 +01:00
parent fff03ac6ad
commit e8bbb481fc
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
28 changed files with 698 additions and 75 deletions

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
*.sqlite
*.local
.data
.data/
.idea/

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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 }

View File

@ -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
View File

@ -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
View File

@ -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
View 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
View 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
View 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)
}

View 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}}

View 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}}

View 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}}

View 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}}

View 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)
}
}

View File

@ -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,

View File

@ -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"}))
}

View File

@ -1 +0,0 @@
package pages

View File

@ -1 +0,0 @@
package pages

View File

@ -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>

View File

@ -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
}
}

View File

@ -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

View File

@ -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()}))
}

View File

@ -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
View 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)
}

View File

@ -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"))

View File

@ -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
View 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))
}