Repository: asciimoo/omnom
Pull request #41: Add support for getting the username from an upstream HTTP header
Author: login: hansnielsen
Created: 2025-05-04T00:04:49Z | Merged: 2025-05-05T08:40:50Z

I run Omnom behind an authenticating reverse proxy, like [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy), and that handles all of my user management. This PR makes Omnom able to trust an upstream HTTP header that contains the username. If a user hasn't been seen before, a new user will be automatically created. See [this similar configuration option](https://docs.paperless-ngx.com/configuration/#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) for paperless-ngx.

I haven't done much polish here since I wanted to see how you'd feel about this feature! All the normal pages work as expected (though you explicitly can't log in, log out, or sign up). You still have to generate and use a token for the extensions even though you're also relying on the header auth, largely because this would involve touching the CSRF code and that would be a somewhat complicated refactor. I made the email and OAuth IDs optional on the user model because a remote user header doesn't include that information. The implementation (plain ol' `*string`) is a little janky and I want to fix that.

Let me know if there's stuff you'd like me to change or test!


Diff:
diff --git a/README.md b/README.md
index 7575e6d..68439ca 100644
--- a/README.md
+++ b/README.md
@@ -35,9 +35,12 @@ Settings can be configured in `config.yml` config file - don't forget to restart
 
 ## User handling
 
-Omnom does not store passwords. Login requires one time login tokens or OAuth.
+Omnom does not store passwords. Login requires one time login token, OAuth, or a remote user header.
+
 Login tokens can be requested via email (this requires a valid SMTP configuration in `config.yml`) through the web interface or can be generated from command line using the `./omnom create-token [username] login`.
 
+If you use Omnom behind a reverse proxy with authentication, you can pass the logged-in username in an HTTP header like `Remote-User` to automatically log in. Omnom can be configured to trust the header by setting the `remote_user_header` option in `config.yml`. Remote user header authentication can't be used with OAuth or open signups.
+
 
 ### Command line tool
 
diff --git a/config.yml_sample b/config.yml_sample
index 2bbd7aa..3459757 100644
--- a/config.yml_sample
+++ b/config.yml_sample
@@ -13,6 +13,8 @@ server:
   # leave blank if you don't have custom base URL
   base_url: ""
   secure_cookie: true
+  # Trust any username sent in this header. Only for use behind an authenticating proxy!
+  # remote_user_header: "Remote-User"
 db:
   type: "sqlite"
   connection: "./db.sqlite3"
diff --git a/config/config.go b/config/config.go
index a7c758b..16e9b35 100644
--- a/config/config.go
+++ b/config/config.go
@@ -39,9 +39,10 @@ type App struct {
 }
 
 type Server struct {
-	Address      string `yaml:"address"`
-	BaseURL      string `yaml:"base_url"`
-	SecureCookie bool   `yaml:"secure_cookie"`
+	Address          string `yaml:"address"`
+	BaseURL          string `yaml:"base_url"`
+	SecureCookie     bool   `yaml:"secure_cookie"`
+	RemoteUserHeader string `yaml:"remote_user_header"`
 }
 
 type DB struct {
@@ -121,6 +122,14 @@ func parseConfig(rawConfig []byte) (*Config, error) {
 	if err != nil {
 		return nil, err
 	}
+	if c.Server.RemoteUserHeader != "" {
+		if len(c.OAuth) > 0 {
+			return nil, errors.New("can't specify OAuth providers when remote user header is enabled")
+		}
+		if c.App.DisableSignup == false {
+			return nil, errors.New("user signups must be disabled when remote user header is enabled")
+		}
+	}
 	for pn, _ := range c.OAuth {
 		if _, ok := oauth.Providers[pn]; !ok {
 			return nil, errors.New("invalid oauth provider: " + pn)
diff --git a/model/user.go b/model/user.go
index c1e5099..44584bd 100644
--- a/model/user.go
+++ b/model/user.go
@@ -11,8 +11,8 @@ import (
 type User struct {
 	CommonFields
 	Username         string     `gorm:"unique" json:"username"`
-	Email            string     `gorm:"unique" json:"email"`
-	OAuthID          string     `gorm:"unique" json:"-"`
+	Email            *string    `gorm:"unique" json:"email"`
+	OAuthID          *string    `gorm:"unique" json:"-"`
 	LoginToken       string     `json:"-"`
 	SubmissionTokens []Token    `json:"-"`
 	Bookmarks        []Bookmark `json:"bookmarks"`
@@ -38,7 +38,7 @@ func GetUserByLoginToken(tok string) *User {
 
 func GetUserByOAuthID(id string) *User {
 	var u User
-	err := DB.Where(&User{OAuthID: id}).First(&u).Error
+	err := DB.Where(&User{OAuthID: &id}).First(&u).Error
 	if err != nil {
 		return nil
 	}
@@ -62,10 +62,14 @@ func CreateUser(username, email string) error {
 	if GetUser(username) != nil {
 		return fmt.Errorf("User already exists")
 	}
+	var dbemail *string
+	if email != "" {
+		dbemail = &email
+	}
 	u := &User{
 		Username:   username,
-		Email:      email,
-		OAuthID:    email,
+		Email:      dbemail,
+		OAuthID:    dbemail,
 		LoginToken: GenerateToken(),
 		SubmissionTokens: []Token{Token{
 			Text: GenerateToken(),
diff --git a/templates/layout/base.tpl b/templates/layout/base.tpl
index 844ee31..8cb73cf 100644
--- a/templates/layout/base.tpl
+++ b/templates/layout/base.tpl
@@ -38,8 +38,10 @@
       <div class="navbar-end">
         {{ if .User }}
             <a href="{{ URLFor "Profile" }}" class="navbar-item"><i class="fas fa-user"></i> &nbsp; {{ .User.Username }}</a>
-            <div class="navbar-item"><a href="{{ URLFor "Logout" }}" class="button is-outlined is-info">Logout</a></div>
-        {{ else }}
+              {{ if .AllowManualLogin }}
+                <div class="navbar-item"><a href="{{ URLFor "Logout" }}" class="button is-outlined is-info">Logout</a></div>
+              {{ end }}
+        {{ else if .AllowManualLogin }}
             <div class="navbar-item"><a href="{{ URLFor "Login" }}" class="button is-outlined is-info">Login</a></div>
             {{ if not .DisableSignup }}<div class="navbar-item"><a href="{{ URLFor "Signup" }}" class="button is-outlined is-info">Signup</a></div>{{ end }}
         {{ end }}
diff --git a/templates/profile.tpl b/templates/profile.tpl
index 56a1318..c86ed4a 100644
--- a/templates/profile.tpl
+++ b/templates/profile.tpl
@@ -1,6 +1,7 @@
 {{ define "content" }}
 <div class="content">
-    <h2 class="title">{{ .User.Username }} <span class="is-size-7 is-italic has-text-grey">{{ .User.Email }}</span></h2>
+    <h2 class="title">{{ .User.Username }}
+	<span class="is-size-7 is-italic has-text-grey">{{ or .User.Email "No email provided" }}</span></h2>
     <p>Snapshot storage usage: <strong>{{ .SnapshotsSize | FormatSize }}</strong></p>
     {{ if .DisplayTokens }}
         {{ if not .AddonTokens }}
diff --git a/webapp/user.go b/webapp/user.go
index 45cf5b8..8d77bc0 100644
--- a/webapp/user.go
+++ b/webapp/user.go
@@ -39,7 +39,7 @@ func validateUsername(username string) error {
 func signup(c *gin.Context) {
 	cfg, _ := c.Get("config")
 	if cfg.(*config.Config).App.DisableSignup {
-		return
+		c.Redirect(http.StatusFound, baseURL("/"))
 	}
 	session := sessions.Default(c)
 	tplVars := map[string]interface{}{
@@ -68,7 +68,7 @@ func signup(c *gin.Context) {
 		u := model.GetUser(username)
 		if session.Get("oauth_id") != nil {
 			oauthID, _ := session.Get("oauth_id").(string)
-			u.OAuthID = oauthID
+			u.OAuthID = &oauthID
 			err = model.DB.Save(u).Error
 			if err != nil {
 				setNotification(c, nError, err.Error(), false)
@@ -87,7 +87,7 @@ func signup(c *gin.Context) {
 			return
 		} else {
 			err = mail.Send(
-				u.Email,
+				*u.Email,
 				"Successful registration to Omnom",
 				"login",
 				map[string]interface{}{
@@ -109,6 +109,10 @@ func signup(c *gin.Context) {
 }
 
 func login(c *gin.Context) {
+	cfg, _ := c.Get("config")
+	if cfg.(*config.Config).Server.RemoteUserHeader != "" {
+		c.Redirect(http.StatusFound, baseURL("/"))
+	}
 	uname, ok := c.GetPostForm("username")
 	if ok {
 		u := model.GetUser(uname)
@@ -124,22 +128,26 @@ func login(c *gin.Context) {
 			render(c, http.StatusOK, "login", nil)
 			return
 		}
-		err = mail.Send(
-			u.Email,
-			fmt.Sprintf("Omnom login verification for %s", u.Username),
-			"login",
-			map[string]interface{}{
-				"Token":    u.LoginToken,
-				"Username": u.Username,
-				"BaseURL":  baseURL("/login"),
-			},
-		)
-		if err != nil {
-			setNotification(c, nError, err.Error(), false)
-			render(c, http.StatusOK, "login", nil)
-			return
+		if u.Email != nil {
+			err = mail.Send(
+				*u.Email,
+				fmt.Sprintf("Omnom login verification for %s", u.Username),
+				"login",
+				map[string]interface{}{
+					"Token":    u.LoginToken,
+					"Username": u.Username,
+					"BaseURL":  baseURL("/login"),
+				},
+			)
+			if err != nil {
+				setNotification(c, nError, err.Error(), false)
+				render(c, http.StatusOK, "login", nil)
+				return
+			}
 		}
-		log.Println("New login token generated:", u.LoginToken)
+		cfg, _ := c.Get("config")
+		addr := cfg.(*config.Config).Server.Address
+		log.Printf("Visit %s/login?token=%s to sign in as %s", addr, u.LoginToken, u.Username)
 		render(c, http.StatusOK, "login-confirm", nil)
 		return
 	}
diff --git a/webapp/webapp.go b/webapp/webapp.go
index a801f57..12d8214 100644
--- a/webapp/webapp.go
+++ b/webapp/webapp.go
@@ -214,6 +214,11 @@ func render(c *gin.Context, status int, page string, vars map[string]interface{}
 	for k, v := range vars {
 		tplVars[k] = v
 	}
+	allowManualLogin := true
+	if cfg.(*config.Config).Server.RemoteUserHeader != "" {
+		allowManualLogin = false
+	}
+	tplVars["AllowManualLogin"] = allowManualLogin
 	switch c.Query("format") {
 	case "json":
 		renderJSON(c, status, tplVars)
@@ -285,7 +290,7 @@ func Run(cfg *config.Config) {
 		Secure: cfg.Server.SecureCookie,
 	})
 	e.Use(sessions.Sessions("SID", store))
-	e.Use(SessionMiddleware())
+	e.Use(SessionMiddleware(cfg))
 	e.Use(ConfigMiddleware(cfg))
 	e.Use(CSRFMiddleware())
 	e.Use(ErrorLoggerMiddleware())
@@ -377,7 +382,43 @@ func authRequiredMiddleware(c *gin.Context) {
 	c.Next()
 }
 
-func SessionMiddleware() gin.HandlerFunc {
+func SessionMiddleware(cfg *config.Config) gin.HandlerFunc {
+	if cfg.Server.RemoteUserHeader != "" {
+		// Always trust the username sent in the RemoteUserHeader. The user set
+		// in the session is ignored.
+		header := cfg.Server.RemoteUserHeader
+		return func(c *gin.Context) {
+			// Set the user in the context
+			hUname := c.GetHeader(header)
+			u := model.GetUser(hUname)
+			// Create a user if it doesn't already exist
+			if hUname == "" {
+				log.Printf("remote user header %q was empty or not present, unable to log user in", header)
+			} else if u == nil {
+				log.Print("Automatically creating user based on remote user header")
+				err := validateUsername(hUname)
+				if err == nil {
+					err = model.CreateUser(hUname, "")
+				}
+				if err == nil {
+					u = model.GetUser(hUname)
+				} else {
+					log.Printf("Failed to automatically create user: %v", err)
+				}
+			}
+			c.Set("user", u)
+
+			// Update the session if the username wasn't present
+			session := sessions.Default(c)
+			sUname := session.Get(SID)
+			if sUname == nil || sUname.(string) != hUname {
+				session.Set(SID, hUname)
+				_ = session.Save()
+			}
+
+			c.Next()
+		}
+	}
 	return func(c *gin.Context) {
 		session := sessions.Default(c)
 		uname := session.Get(SID)


Reviews:
user: login: asciimoo
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-04T13:23:23Z
user: login: hansnielsen
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T07:17:30Z

Review comments:
diff_hunk: @@ -11,8 +11,8 @@ import (
 type User struct {
 	CommonFields
 	Username         string     `gorm:"unique" json:"username"`
-	Email            string     `gorm:"unique" json:"email"`
-	OAuthID          string     `gorm:"unique" json:"-"`
+	Email            *string    `gorm:"unique" json:"email"`
path: model/user.go
user: login: asciimoo
body: Why do you change these two fields to pointers?
created_at: 2025-05-04T13:23:23Z
author_association: OWNER
diff_hunk: @@ -11,8 +11,8 @@ import (
 type User struct {
 	CommonFields
 	Username         string     `gorm:"unique" json:"username"`
-	Email            string     `gorm:"unique" json:"email"`
-	OAuthID          string     `gorm:"unique" json:"-"`
+	Email            *string    `gorm:"unique" json:"email"`
path: model/user.go
user: login: hansnielsen
body: Users that get automatically created by the username header don't have an email associated with them, and only one row can have the empty string as a value because of the unique constraint. There's no such limit for SQL nulls, which are always considered to be unique, so we can have multiple automatic users.
created_at: 2025-05-05T07:17:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: asciimoo
created_at: 2025-05-04T13:27:48Z
body: Seems like a useful addition, thanks! Could you add a little description about this feature to the README's user handling section?
author_association: OWNER
user: login: asciimoo
created_at: 2025-05-05T08:40:35Z
body: Great, thanks!
author_association: OWNER