diff --git a/.gitignore b/.gitignore index 26213bd..72ebcf4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ *.sqlite *.local -.data +.data/ .idea/ diff --git a/cmd/tulip/conf.go b/cmd/tulip/conf.go index 89a8337..01af020 100644 --- a/cmd/tulip/conf.go +++ b/cmd/tulip/conf.go @@ -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"` } diff --git a/cmd/tulip/serve.go b/cmd/tulip/serve.go index 874a1e2..204d253 100644 --- a/cmd/tulip/serve.go +++ b/cmd/tulip/serve.go @@ -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) } diff --git a/database/db-types.go b/database/db-types.go index a5e6f7c..7887a9b 100644 --- a/database/db-types.go +++ b/database/db-types.go @@ -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 } diff --git a/database/tx.go b/database/tx.go index 872f481..a8ff289 100644 --- a/database/tx.go +++ b/database/tx.go @@ -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 } diff --git a/go.mod b/go.mod index d6aa5a8..fed5174 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index b182558..e910d24 100644 --- a/go.sum +++ b/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= diff --git a/mail/from-address.go b/mail/from-address.go new file mode 100644 index 0000000..e52f5f8 --- /dev/null +++ b/mail/from-address.go @@ -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 +} diff --git a/mail/mail.go b/mail/mail.go new file mode 100644 index 0000000..aef03c1 --- /dev/null +++ b/mail/mail.go @@ -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) +} diff --git a/mail/send-template.go b/mail/send-template.go new file mode 100644 index 0000000..a15fdd3 --- /dev/null +++ b/mail/send-template.go @@ -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) +} diff --git a/mail/templates/mail-account-delete.go.txt b/mail/templates/mail-account-delete.go.txt new file mode 100644 index 0000000..6fed9ed --- /dev/null +++ b/mail/templates/mail-account-delete.go.txt @@ -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}} diff --git a/mail/templates/mail-register-delete.go.txt b/mail/templates/mail-register-delete.go.txt new file mode 100644 index 0000000..522646d --- /dev/null +++ b/mail/templates/mail-register-delete.go.txt @@ -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}} diff --git a/mail/templates/mail-reset-password.go.txt b/mail/templates/mail-reset-password.go.txt new file mode 100644 index 0000000..da735dd --- /dev/null +++ b/mail/templates/mail-reset-password.go.txt @@ -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}} diff --git a/mail/templates/mail-verify.go.txt b/mail/templates/mail-verify.go.txt new file mode 100644 index 0000000..12689e3 --- /dev/null +++ b/mail/templates/mail-verify.go.txt @@ -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}} diff --git a/mail/templates/templates.go b/mail/templates/templates.go new file mode 100644 index 0000000..e773946 --- /dev/null +++ b/mail/templates/templates.go @@ -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) + } +} diff --git a/openid/config.go b/openid/config.go index 77f5e37..3abb73a 100644 --- a/openid/config.go +++ b/openid/config.go @@ -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, diff --git a/openid/config_test.go b/openid/config_test.go index 125504d..6cd2cc9 100644 --- a/openid/config_test.go +++ b/openid/config_test.go @@ -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"})) } diff --git a/pages/edit-password.go.html b/pages/edit-password.go.html index 76e382d..e69de29 100644 --- a/pages/edit-password.go.html +++ b/pages/edit-password.go.html @@ -1 +0,0 @@ -package pages diff --git a/pages/edit-username.go.html b/pages/edit-username.go.html index 76e382d..e69de29 100644 --- a/pages/edit-username.go.html +++ b/pages/edit-username.go.html @@ -1 +0,0 @@ -package pages diff --git a/pages/login.go.html b/pages/login.go.html index 9b8d6b8..4802a16 100644 --- a/pages/login.go.html +++ b/pages/login.go.html @@ -8,6 +8,12 @@

{{.ServiceName}}

+ {{if eq .Mismatch "1"}} +

Invalid username or password

+ {{end}} + {{if eq .Mismatch "2"}} +

Check your inbox for a verification email

+ {{end}}
diff --git a/scope/scope.go b/scope/scope.go index 87f6ce9..df20e0d 100644 --- a/scope/scope.go +++ b/scope/scope.go @@ -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 + } +} diff --git a/server/auth.go b/server/auth.go index 4127188..9cf02d4 100644 --- a/server/auth.go +++ b/server/auth.go @@ -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 diff --git a/server/auth_test.go b/server/auth_test.go index 660b9c6..5a56732 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -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()})) } diff --git a/server/login.go b/server/login.go index 01fe4ee..77c9242 100644 --- a/server/login.go +++ b/server/login.go @@ -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 { diff --git a/server/mail.go b/server/mail.go new file mode 100644 index 0000000..f3efddc --- /dev/null +++ b/server/mail.go @@ -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) +} diff --git a/server/manage-users.go b/server/manage-users.go index 76b3b1b..df95eb5 100644 --- a/server/manage-users.go +++ b/server/manage-users.go @@ -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")) diff --git a/server/server.go b/server/server.go index e0219a5..e218efb 100644 --- a/server/server.go +++ b/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 +} diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..374a981 --- /dev/null +++ b/server/server_test.go @@ -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)) +}