Invalid username or password
+ {{end}} + {{if eq .Mismatch "2"}} +Check your inbox for a verification email
+ {{end}}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 @@
Invalid username or password
+ {{end}} + {{if eq .Mismatch "2"}} +Check your inbox for a verification email
+ {{end}}