diff --git a/cmd/tulip/serve.go b/cmd/tulip/serve.go index 87f7a0f..78d494b 100644 --- a/cmd/tulip/serve.go +++ b/cmd/tulip/serve.go @@ -55,7 +55,7 @@ func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...any) subcomm return subcommands.ExitFailure } - var config startUpConfig + var config server.Conf err = json.NewDecoder(openConf).Decode(&config) if err != nil { log.Println("[Tulip] Error: invalid config file: ", err) @@ -71,7 +71,7 @@ func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...any) subcomm return subcommands.ExitSuccess } -func normalLoad(startUp startUpConfig, wd string) { +func normalLoad(startUp server.Conf, wd string) { key := genHmacKey() db, err := database.Open(filepath.Join(wd, "tulip.db.sqlite")) @@ -91,7 +91,7 @@ func normalLoad(startUp startUpConfig, wd string) { log.Fatal("[Tulip] Failed to load mail templates:", err) } - srv := server.NewHttpServer(startUp.Listen, startUp.BaseUrl, startUp.OtpIssuer, startUp.ServiceName, startUp.Mail, db, key) + srv := server.NewHttpServer(startUp, db, key) log.Printf("[Tulip] Starting HTTP server on '%s'\n", srv.Addr) go utils.RunBackgroundHttp("HTTP", srv) @@ -122,7 +122,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", false, database.RoleAdmin, false) if err != nil { return fmt.Errorf("failed to add user: %w", err) } diff --git a/database/init.sql b/database/init.sql index 4fb328e..ed54fae 100644 --- a/database/init.sql +++ b/database/init.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS users locale TEXT DEFAULT "en-US" NOT NULL, role INTEGER DEFAULT 0 NOT NULL, updated_at DATETIME, + registered INTEGER DEFAULT 0, active INTEGER DEFAULT 1 ); diff --git a/database/tx.go b/database/tx.go index 818fb52..2b005e6 100644 --- a/database/tx.go +++ b/database/tx.go @@ -37,13 +37,13 @@ func (t *Tx) HasUser() error { return nil } -func (t *Tx) InsertUser(name, un, pw, email string, role UserRole, active bool) (uuid.UUID, error) { +func (t *Tx) InsertUser(name, un, pw, email string, verifyEmail bool, role UserRole, active bool) (uuid.UUID, error) { pwHash, err := password.HashPassword(pw) if err != nil { return uuid.UUID{}, 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) + _, err = t.tx.Exec(`INSERT INTO users (subject, name, username, password, email, email_verified, role, updated_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, u, name, un, pwHash, email, verifyEmail, role, updatedAt(), active) return u, err } @@ -235,18 +235,18 @@ func (t *Tx) InsertClientApp(name, domain string, sso, active bool, owner uuid.U return err } -func (t *Tx) UpdateClientApp(subject uuid.UUID, name, domain string, sso, active bool) error { - _, err := t.tx.Exec(`UPDATE client_store SET name = ?, domain = ?, sso = ?, active = ? WHERE subject = ?`, name, domain, sso, active, subject.String()) +func (t *Tx) UpdateClientApp(subject, owner uuid.UUID, name, domain string, sso, active bool) error { + _, err := t.tx.Exec(`UPDATE client_store SET name = ?, domain = ?, sso = ?, active = ? WHERE subject = ? AND owner = ?`, name, domain, sso, active, subject.String(), owner.String()) return err } -func (t *Tx) ResetClientAppSecret(subject uuid.UUID, secret string) error { +func (t *Tx) ResetClientAppSecret(subject, owner uuid.UUID) (string, error) { secret, err := password.GenerateApiSecret(70) if err != nil { - return err + return "", err } - _, err = t.tx.Exec(`UPDATE client_store SET secret = ? WHERE subject = ?`, secret, subject.String()) - return err + _, err = t.tx.Exec(`UPDATE client_store SET secret = ? WHERE subject = ? AND owner = ?`, secret, subject.String(), owner.String()) + return secret, err } func (t *Tx) GetUserList(offset int) ([]User, error) { diff --git a/pages/manage-apps.go.html b/pages/manage-apps.go.html index 8c544af..c173655 100644 --- a/pages/manage-apps.go.html +++ b/pages/manage-apps.go.html @@ -2,6 +2,30 @@ {{.ServiceName}} +
@@ -12,6 +36,10 @@ + {{if .NewAppSecret}} +
New application secret: {{.NewAppSecret}} for {{.NewAppName}}
+ {{end}} + {{if .Edit}}

Edit Client Application

@@ -31,11 +59,13 @@ {{if .IsAdmin}}
- +
{{end}}
- +
@@ -75,6 +105,12 @@ +
+ + + + +
{{end}} @@ -100,7 +136,8 @@ {{end}}
- +
diff --git a/pages/manage-users.go.html b/pages/manage-users.go.html index bac79f9..f871279 100644 --- a/pages/manage-users.go.html +++ b/pages/manage-users.go.html @@ -37,7 +37,8 @@
- +
@@ -98,6 +99,10 @@ +
+ + +
{{end}} @@ -127,6 +132,8 @@
+

Using an `@{{.Namespace}}` email address will automatically verify as it is owned by this login + service.

@@ -137,7 +144,8 @@
- +
diff --git a/cmd/tulip/conf.go b/server/conf.go similarity index 76% rename from cmd/tulip/conf.go rename to server/conf.go index 01af020..24827ed 100644 --- a/cmd/tulip/conf.go +++ b/server/conf.go @@ -1,11 +1,12 @@ -package main +package server import "github.com/1f349/tulip/mail" -type startUpConfig struct { +type Conf struct { Listen string `json:"listen"` BaseUrl string `json:"base_url"` OtpIssuer string `json:"otp_issuer"` ServiceName string `json:"service_name"` + Namespace string `json:"namespace"` Mail mail.Mail `json:"mail"` } diff --git a/server/edit.go b/server/edit.go index 058c862..8e35039 100644 --- a/server/edit.go +++ b/server/edit.go @@ -31,7 +31,7 @@ func (h *HttpServer) EditGet(rw http.ResponseWriter, _ *http.Request, _ httprout return } pages.RenderPageTemplate(rw, "edit", map[string]any{ - "ServiceName": h.serviceName, + "ServiceName": h.conf.ServiceName, "User": user, "Nonce": lNonce, "FieldPronoun": user.Pronouns.String(), diff --git a/server/home.go b/server/home.go index b2353e1..7ff3be5 100644 --- a/server/home.go +++ b/server/home.go @@ -14,7 +14,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute rw.WriteHeader(http.StatusOK) if auth.IsGuest() { pages.RenderPageTemplate(rw, "index-guest", map[string]any{ - "ServiceName": h.serviceName, + "ServiceName": h.conf.ServiceName, }) return } @@ -37,7 +37,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute return } pages.RenderPageTemplate(rw, "index", map[string]any{ - "ServiceName": h.serviceName, + "ServiceName": h.conf.ServiceName, "Auth": auth, "User": userWithName, "Nonce": lNonce, diff --git a/server/login.go b/server/login.go index 2794b8d..f4b113c 100644 --- a/server/login.go +++ b/server/login.go @@ -43,7 +43,7 @@ func (h *HttpServer) LoginGet(rw http.ResponseWriter, req *http.Request, _ httpr rw.Header().Set("Content-Type", "text/html") rw.WriteHeader(http.StatusOK) pages.RenderPageTemplate(rw, "login", map[string]any{ - "ServiceName": h.serviceName, + "ServiceName": h.conf.ServiceName, "Redirect": req.URL.Query().Get("redirect"), "Mismatch": req.URL.Query().Get("mismatch"), "LoginName": loginName, @@ -100,8 +100,8 @@ func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ http 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(), + err = h.conf.Mail.SendEmailTemplate("mail-verify", "Verify Email", userInfo.Name, address, map[string]any{ + "VerifyUrl": h.conf.BaseUrl + "/mail/verify/" + u.String(), }) if err != nil { log.Println("[Tulip] Login: Failed to send verification email:", err) diff --git a/server/mail.go b/server/mail.go index f88cd9a..a26db0b 100644 --- a/server/mail.go +++ b/server/mail.go @@ -68,7 +68,7 @@ func (h *HttpServer) MailPassword(rw http.ResponseWriter, req *http.Request, par } pages.RenderPageTemplate(rw, "reset-password", map[string]any{ - "ServiceName": h.serviceName, + "ServiceName": h.conf.ServiceName, }) } @@ -155,7 +155,7 @@ func (h *HttpServer) MailDelete(rw http.ResponseWriter, req *http.Request, param return } - err = h.mailer.SendEmailTemplate("mail-account-delete", "Account Deletion", userInfo.Name, address, nil) + err = h.conf.Mail.SendEmailTemplate("mail-account-delete", "Account Deletion", userInfo.Name, address, nil) if err != nil { http.Error(rw, "Failed to send confirmation email.", http.StatusInternalServerError) return diff --git a/server/manage-apps.go b/server/manage-apps.go index 5638edb..514f28c 100644 --- a/server/manage-apps.go +++ b/server/manage-apps.go @@ -3,6 +3,7 @@ package server import ( "github.com/1f349/tulip/database" "github.com/1f349/tulip/pages" + "github.com/go-oauth2/oauth2/v4" "github.com/google/uuid" "github.com/julienschmidt/httprouter" "net/http" @@ -36,10 +37,12 @@ func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ } m := map[string]any{ - "ServiceName": h.serviceName, - "Apps": appList, - "Offset": offset, - "IsAdmin": role == database.RoleAdmin, + "ServiceName": h.conf.ServiceName, + "Apps": appList, + "Offset": offset, + "IsAdmin": role == database.RoleAdmin, + "NewAppName": q.Get("NewAppName"), + "NewAppSecret": q.Get("NewAppSecret"), } if q.Has("edit") { for _, i := range appList { @@ -99,10 +102,43 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ if err != nil { return err } - return tx.UpdateClientApp(sub, name, domain, sso, active) + return tx.UpdateClientApp(sub, auth.Data.ID, name, domain, sso, active) }) { return } + case "secret": + var info oauth2.ClientInfo + var secret string + if h.DbTx(rw, func(tx *database.Tx) error { + sub, err := uuid.Parse(req.Form.Get("subject")) + if err != nil { + return err + } + info, err = tx.GetClientInfo(sub.String()) + if err != nil { + return err + } + secret, err = tx.ResetClientAppSecret(sub, auth.Data.ID) + return err + }) { + return + } + + appName := "Unknown..." + if getName, ok := info.(interface{ GetName() string }); ok { + appName = getName.GetName() + } + + h.ManageAppsGet(rw, &http.Request{ + URL: &url.URL{ + RawQuery: url.Values{ + "offset": []string{offset}, + "NewAppName": []string{appName}, + "NewAppSecret": []string{secret}, + }.Encode(), + }, + }, httprouter.Params{}, auth) + return default: http.Error(rw, "400 Bad Request: Invalid action", http.StatusBadRequest) return diff --git a/server/manage-users.go b/server/manage-users.go index df95eb5..f7ec332 100644 --- a/server/manage-users.go +++ b/server/manage-users.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" ) @@ -44,11 +45,12 @@ func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ } m := map[string]any{ - "ServiceName": h.serviceName, + "ServiceName": h.conf.ServiceName, "Users": userList, "Offset": offset, "EmailShow": req.URL.Query().Has("show-email"), "CurrentAdmin": auth.Data.ID, + "Namespace": h.conf.Namespace, } if q.Has("edit") { for _, i := range userList { @@ -100,28 +102,33 @@ func (h *HttpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, switch action { case "create": - 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 } + n := strings.IndexByte(address.Address, '@') + // This case should never happen and fail the above address parsing + if n == -1 { + return + } + addrDomain := address.Address[n+1:] + + var userSub uuid.UUID + if h.DbTx(rw, func(tx *database.Tx) (err error) { + userSub, err = tx.InsertUser(name, username, "", email, addrDomain == h.conf.Namespace, newRole, active) + return err + }) { + 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(), + err = h.conf.Mail.SendEmailTemplate("mail-register-admin", "Register", name, address, map[string]any{ + "RegisterUrl": h.conf.BaseUrl + "/mail/password/" + u.String(), }) if err != nil { log.Println("[Tulip] Login: Failed to send register email:", err) diff --git a/server/oauth.go b/server/oauth.go index 770bc98..3c98b21 100644 --- a/server/oauth.go +++ b/server/oauth.go @@ -95,7 +95,7 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request rw.WriteHeader(http.StatusOK) pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{ - "ServiceName": h.serviceName, + "ServiceName": h.conf.ServiceName, "AppName": appName, "AppDomain": appDomain, "User": user, diff --git a/server/otp.go b/server/otp.go index cfd4ce6..b9bf44d 100644 --- a/server/otp.go +++ b/server/otp.go @@ -19,7 +19,7 @@ func (h *HttpServer) LoginOtpGet(rw http.ResponseWriter, req *http.Request, _ ht } pages.RenderPageTemplate(rw, "login-otp", map[string]any{ - "ServiceName": h.serviceName, + "ServiceName": h.conf.ServiceName, "Redirect": req.URL.Query().Get("redirect"), }) } @@ -53,7 +53,7 @@ func (h *HttpServer) fetchAndValidateOtp(rw http.ResponseWriter, sub uuid.UUID, return } if hasOtp { - otp, err = tx.GetTwoFactor(sub, h.otpIssuer) + otp, err = tx.GetTwoFactor(sub, h.conf.OtpIssuer) } return }) { @@ -121,7 +121,7 @@ func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ htt // generate OTP key var err error - otp, err = twofactor.NewTOTP(email, h.otpIssuer, crypto.SHA512, digits) + otp, err = twofactor.NewTOTP(email, h.conf.OtpIssuer, crypto.SHA512, digits) if err != nil { http.Error(rw, "500 Internal Server Error: Failed to generate OTP key", http.StatusInternalServerError) return @@ -150,7 +150,7 @@ func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ htt // render page pages.RenderPageTemplate(rw, "edit-otp", map[string]any{ - "ServiceName": h.serviceName, + "ServiceName": h.conf.ServiceName, "OtpQr": template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(otpQr)), "OtpUrl": otpUrl, }) diff --git a/server/server.go b/server/server.go index 7242c60..0185e28 100644 --- a/server/server.go +++ b/server/server.go @@ -8,7 +8,6 @@ import ( "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/openid" scope2 "github.com/1f349/tulip/scope" "github.com/go-oauth2/oauth2/v4/errors" @@ -28,15 +27,12 @@ import ( var errInvalidScope = errors.New("missing required scope") type HttpServer struct { - r *httprouter.Router - oauthSrv *server.Server - oauthMgr *manage.Manager - db *database.DB - domain string - privKey []byte - otpIssuer string - serviceName string - mailer mail.Mail + r *httprouter.Router + oauthSrv *server.Server + oauthMgr *manage.Manager + db *database.DB + conf Conf + privKey []byte // mailLinkCache contains a mapping of verify uuids to user uuids mailLinkCache *cache.Cache[mailLinkKey, uuid.UUID] @@ -71,10 +67,18 @@ 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, mailer mail.Mail, db *database.DB, privKey []byte) *http.Server { +func NewHttpServer(conf Conf, db *database.DB, privKey []byte) *http.Server { r := httprouter.New() - 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"}) + // remove last slash from baseUrl + { + l := len(conf.BaseUrl) + if conf.BaseUrl[l-1] == '/' { + conf.BaseUrl = conf.BaseUrl[:l-1] + } + } + + openIdConf := openid.GenConfig(conf.BaseUrl, []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) @@ -83,15 +87,12 @@ func NewHttpServer(listen, domain, otpIssuer, serviceName string, mailer mail.Ma oauthManager := manage.NewDefaultManager() oauthSrv := server.NewServer(server.NewConfig(), oauthManager) hs := &HttpServer{ - r: httprouter.New(), - oauthSrv: oauthSrv, - oauthMgr: oauthManager, - db: db, - domain: domain, - privKey: privKey, - otpIssuer: otpIssuer, - serviceName: serviceName, - mailer: mailer, + r: httprouter.New(), + oauthSrv: oauthSrv, + oauthMgr: oauthManager, + db: db, + conf: conf, + privKey: privKey, mailLinkCache: cache.New[mailLinkKey, uuid.UUID](), } @@ -220,10 +221,10 @@ func NewHttpServer(listen, domain, otpIssuer, serviceName string, mailer mail.Ma m["name"] = userData.Name } if claims["username"] { - m["preferred_username"] = userData.Name + m["preferred_username"] = userData.Username } if claims["profile"] { - m["profile"] = domain + "/user/" + userData.Username + m["profile"] = conf.BaseUrl + "/user/" + userData.Username m["picture"] = userData.Picture.String() m["website"] = userData.Website.String() } @@ -249,7 +250,7 @@ func NewHttpServer(listen, domain, otpIssuer, serviceName string, mailer mail.Ma }) return &http.Server{ - Addr: listen, + Addr: conf.Listen, Handler: r, ReadTimeout: time.Minute, ReadHeaderTimeout: time.Minute,