API: Move handling of HTTP auth headers to pkg/header #808 #3943 #3959

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2024-01-09 10:58:47 +01:00
parent 881bc4cb28
commit 3e924b70c7
21 changed files with 234 additions and 158 deletions

View file

@ -6,14 +6,17 @@ import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/header"
)
// Auth checks if the user has permission to access the specified resource and returns the session if so.
// Auth checks if the user is authorized to access a resource with the given permission
// and returns the session or nil otherwise.
func Auth(c *gin.Context, resource acl.Resource, grant acl.Permission) *entity.Session {
return AuthAny(c, resource, acl.Permissions{grant})
}
// AuthAny checks if at least one permission allows access and returns the session in this case.
// AuthAny checks if the user is authorized to access a resource with any of the specified permissions
// and returns the session or nil otherwise.
func AuthAny(c *gin.Context, resource acl.Resource, grants acl.Permissions) (s *entity.Session) {
// Get the client IP and session ID from the request headers.
ip := ClientIP(c)
@ -75,3 +78,9 @@ func AuthAny(c *gin.Context, resource acl.Resource, grants acl.Permissions) (s *
return s
}
}
// AuthToken returns the client authentication token from the request context if one was found,
// or an empty string if no supported request header value was provided.
func AuthToken(c *gin.Context) string {
return header.AuthToken(c)
}

View file

@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/pkg/header"
)
func TestAuth(t *testing.T) {
@ -22,7 +23,7 @@ func TestAuth(t *testing.T) {
}
// Add authorization header.
AddRequestAuthorizationHeader(c.Request, session.PublicAuthToken)
header.SetAuthorization(c.Request, session.PublicAuthToken)
// Check auth token.
authToken := AuthToken(c)
@ -56,7 +57,7 @@ func TestAuthAny(t *testing.T) {
}
// Add authorization header.
AddRequestAuthorizationHeader(c.Request, session.PublicAuthToken)
header.SetAuthorization(c.Request, session.PublicAuthToken)
// Check auth token.
authToken := AuthToken(c)
@ -87,3 +88,52 @@ func TestAuthAny(t *testing.T) {
assert.False(t, s.Abort(c))
})
}
func TestAuthToken(t *testing.T) {
t.Run("None", func(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = &http.Request{
Header: make(http.Header),
}
// No headers have been set, so no token should be returned.
token := AuthToken(c)
assert.Equal(t, "", token)
})
t.Run("BearerToken", func(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = &http.Request{
Header: make(http.Header),
}
// Add authorization header.
header.SetAuthorization(c.Request, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")
// Check result.
authToken := AuthToken(c)
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", authToken)
bearerToken := header.BearerToken(c)
assert.Equal(t, authToken, bearerToken)
})
t.Run("Header", func(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = &http.Request{
Header: make(http.Header),
}
// Add authorization header.
c.Request.Header.Add(header.XAuthToken, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")
// Check result.
authToken := AuthToken(c)
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", authToken)
bearerToken := header.BearerToken(c)
assert.Equal(t, "", bearerToken)
})
}

View file

@ -0,0 +1,17 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/pkg/header"
)
// ClientIP returns the client IP address from the request context or a placeholder if it is unknown.
func ClientIP(c *gin.Context) (ip string) {
return header.ClientIP(c)
}
// UserAgent returns the user agent from the request context or an empty string if it is unknown.
func UserAgent(c *gin.Context) string {
return header.UserAgent(c)
}

View file

@ -1,94 +0,0 @@
package api
import (
"crypto/sha1"
"encoding/base64"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/header"
)
// AuthToken returns the client authentication token from the request context,
// or an empty string if none is found.
func AuthToken(c *gin.Context) string {
// Default is an empty string if no context or ID is set.
if c == nil {
return ""
}
// First check the "X-Auth-Token" and "X-Session-ID" headers for an auth token.
if token := c.GetHeader(header.AuthToken); token != "" {
return clean.ID(token)
} else if id := c.GetHeader(header.SessionID); id != "" {
return clean.ID(id)
}
// Otherwise, the bearer token from the authorization request header is returned.
return BearerToken(c)
}
// BearerToken returns the client bearer token header value, or an empty string if none is found.
func BearerToken(c *gin.Context) string {
if authType, bearerToken := Authorization(c); authType == header.BearerAuth && bearerToken != "" {
return bearerToken
}
return ""
}
// Authorization returns the authentication type and token from the authorization request header,
// or an empty string if there is none.
func Authorization(c *gin.Context) (authType, authToken string) {
if c == nil {
return "", ""
} else if s := c.GetHeader(header.Authorization); s == "" {
// Ignore.
} else if t := strings.Split(s, " "); len(t) != 2 {
// Ignore.
} else {
return clean.ID(t[0]), clean.ID(t[1])
}
return "", ""
}
// AddRequestAuthorizationHeader adds a bearer token authorization header to a request.
func AddRequestAuthorizationHeader(r *http.Request, authToken string) {
if authToken != "" {
r.Header.Add(header.Authorization, fmt.Sprintf("%s %s", header.BearerAuth, authToken))
}
}
// BasicAuth checks the basic authorization header for credentials and returns them if found.
//
// Note that OAuth 2.0 defines basic authentication differently than RFC 7617, however, this
// does not matter as long as only alphanumeric characters are used for client id and secret:
// https://www.scottbrady91.com/oauth/client-authentication#:~:text=OAuth%20Basic%20Authentication
func BasicAuth(c *gin.Context) (username, password, cacheKey string) {
authType, authToken := Authorization(c)
if authType != header.BasicAuth || authToken == "" {
return "", "", ""
}
auth, err := base64.StdEncoding.DecodeString(authToken)
if err != nil {
return "", "", ""
}
credentials := strings.SplitN(string(auth), ":", 2)
if len(credentials) != 2 {
return "", "", ""
}
cacheKey = fmt.Sprintf("%x", sha1.Sum([]byte(authToken)))
return credentials[0], credentials[1], cacheKey
}

View file

@ -33,7 +33,7 @@ func AddDownloadHeader(c *gin.Context, fileName string) {
// AddSessionHeader adds a session id header to the response.
func AddSessionHeader(c *gin.Context, id string) {
c.Header(header.SessionID, id)
c.Header(header.XSessionID, id)
}
// AddContentTypeHeader adds a content type header to the response.

View file

@ -109,7 +109,7 @@ func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, name string, pas
Password: password,
}))
authToken = r.Header().Get(header.SessionID)
authToken = r.Header().Get(header.XSessionID)
return
}
@ -118,7 +118,7 @@ func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, name string, pas
func AuthenticatedRequest(r http.Handler, method, path, authToken string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(method, path, nil)
AddRequestAuthorizationHeader(req, authToken)
header.SetAuthorization(req, authToken)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@ -131,7 +131,7 @@ func AuthenticatedRequestWithBody(r http.Handler, method, path, body string, aut
reader := strings.NewReader(body)
req, _ := http.NewRequest(method, path, reader)
AddRequestAuthorizationHeader(req, authToken)
header.SetAuthorization(req, authToken)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

View file

@ -15,6 +15,7 @@ import (
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/header"
"github.com/photoprism/photoprism/pkg/rnd"
)
@ -39,7 +40,7 @@ func CreateOAuthToken(router *gin.RouterGroup) {
var f form.ClientCredentials
// Allow authentication with basic auth and form values.
if clientId, clientSecret, _ := BasicAuth(c); clientId != "" && clientSecret != "" {
if clientId, clientSecret, _ := header.BasicAuth(c); clientId != "" && clientSecret != "" {
f.ClientID = clientId
f.ClientSecret = clientSecret
} else if err = c.Bind(&f); err != nil {

View file

@ -236,7 +236,7 @@ func TestDeleteOAuthToken(t *testing.T) {
authToken := gjson.Get(createResp.Body.String(), "access_token").String()
deleteToken, _ := http.NewRequest("POST", logoutPath, nil)
deleteToken.Header.Add(header.AuthToken, authToken)
deleteToken.Header.Add(header.XAuthToken, authToken)
deleteResp := httptest.NewRecorder()
app.ServeHTTP(deleteResp, deleteToken)
@ -253,7 +253,7 @@ func TestDeleteOAuthToken(t *testing.T) {
sess := entity.SessionFixtures.Get("alice_token")
deleteToken, _ := http.NewRequest("POST", "/api/v1/oauth/logout", nil)
deleteToken.Header.Add(header.AuthToken, sess.AuthToken())
deleteToken.Header.Add(header.XAuthToken, sess.AuthToken())
deleteResp := httptest.NewRecorder()
app.ServeHTTP(deleteResp, deleteToken)

View file

@ -180,7 +180,7 @@ func (m *Session) SetAuthToken(authToken string) *Session {
// AuthTokenType returns the authentication token type.
func (m *Session) AuthTokenType() string {
return header.BearerAuth
return header.AuthBearer
}
// Regenerate (re-)initializes the session with a random auth token, ID, and RefID.

View file

@ -182,32 +182,32 @@ func TestSession_AuthToken(t *testing.T) {
assert.Equal(t, "", sess.AuthToken())
assert.False(t, rnd.IsSessionID(sess.ID))
assert.False(t, rnd.IsAuthToken(sess.AuthToken()))
assert.Equal(t, header.BearerAuth, sess.AuthTokenType())
assert.Equal(t, header.AuthBearer, sess.AuthTokenType())
sess.Regenerate()
assert.True(t, rnd.IsSessionID(sess.ID))
assert.True(t, rnd.IsAuthToken(sess.AuthToken()))
assert.Equal(t, header.BearerAuth, sess.AuthTokenType())
assert.Equal(t, header.AuthBearer, sess.AuthTokenType())
sess.SetAuthToken(alice.AuthToken())
assert.Equal(t, "a3859489780243a78b331bd44f58255b552dee104041a45c0e79b610f63af2e5", sess.ID)
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", sess.AuthToken())
assert.Equal(t, header.BearerAuth, sess.AuthTokenType())
assert.Equal(t, header.AuthBearer, sess.AuthTokenType())
})
t.Run("Alice", func(t *testing.T) {
sess := SessionFixtures.Get("alice")
assert.Equal(t, "a3859489780243a78b331bd44f58255b552dee104041a45c0e79b610f63af2e5", sess.ID)
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", sess.AuthToken())
assert.Equal(t, header.BearerAuth, sess.AuthTokenType())
assert.Equal(t, header.AuthBearer, sess.AuthTokenType())
})
t.Run("Find", func(t *testing.T) {
alice := SessionFixtures.Get("alice")
sess := FindSessionByRefID("sessxkkcabcd")
assert.Equal(t, "a3859489780243a78b331bd44f58255b552dee104041a45c0e79b610f63af2e5", sess.ID)
assert.Equal(t, "", sess.AuthToken())
assert.Equal(t, header.BearerAuth, sess.AuthTokenType())
assert.Equal(t, header.AuthBearer, sess.AuthTokenType())
sess.SetAuthToken(alice.AuthToken())
assert.Equal(t, "a3859489780243a78b331bd44f58255b552dee104041a45c0e79b610f63af2e5", sess.ID)
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", sess.AuthToken())
assert.Equal(t, header.BearerAuth, sess.AuthTokenType())
assert.Equal(t, header.AuthBearer, sess.AuthTokenType())
})
}

View file

@ -18,6 +18,7 @@ import (
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/header"
)
// To improve performance, we use a basic auth cache
@ -33,7 +34,7 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc {
// Helper function that extracts the login information from the request headers.
var basicAuth = func(c *gin.Context) (username, password, cacheKey string, authorized bool) {
// Extract credentials from the HTTP request headers.
username, password, cacheKey = api.BasicAuth(c)
username, password, cacheKey = header.BasicAuth(c)
// Fail if the username or password is empty, as
// this is not allowed under any circumstances.

View file

@ -15,6 +15,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/header"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -87,12 +88,12 @@ func WebDAV(filePath string, router *gin.RouterGroup, conf *config.Config) {
if fs.FileExists(fileName) {
// Flag the uploaded file as favorite if the "X-Favorite" header is set to "1".
if r.Header.Get("X-Favorite") == "1" {
if r.Header.Get(header.XFavorite) == "1" {
FlagUploadAsFavorite(fileName)
}
// Set the file modification time based on the Unix timestamp found in the "X-OC-MTime" header.
if mtimeUnix := txt.Int64(r.Header.Get("X-OC-MTime")); mtimeUnix <= 0 {
if mtimeUnix := txt.Int64(r.Header.Get(header.XModTime)); mtimeUnix <= 0 {
// Ignore, as no Unix timestamp was provided.
} else if mtime := time.Unix(mtimeUnix, 0); mtime.IsZero() || time.Now().Before(mtime) {
log.Warnf("webdav: invalid modtime provided for %s", clean.Log(filepath.Base(fileName)))

View file

@ -1,9 +1,100 @@
package header
const (
SessionID = "X-Session-ID"
AuthToken = "X-Auth-Token"
Authorization = "Authorization" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization
BasicAuth = "Basic"
BearerAuth = "Bearer"
import (
"crypto/sha1"
"encoding/base64"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/pkg/clean"
)
const (
XAuthToken = "X-Auth-Token"
XSessionID = "X-Session-ID"
Auth = "Authorization" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization
AuthBasic = "Basic"
AuthBearer = "Bearer"
)
// AuthToken returns the client authentication token from the request context,
// or an empty string if none is found.
func AuthToken(c *gin.Context) string {
// Default is an empty string if no context or ID is set.
if c == nil {
return ""
}
// First check the "X-Auth-Token" and "X-Session-ID" headers for an auth token.
if token := c.GetHeader(XAuthToken); token != "" {
return clean.ID(token)
} else if id := c.GetHeader(XSessionID); id != "" {
return clean.ID(id)
}
// Otherwise, the bearer token from the authorization request header is returned.
return BearerToken(c)
}
// BearerToken returns the client bearer token header value, or an empty string if none is found.
func BearerToken(c *gin.Context) string {
if authType, bearerToken := Authorization(c); authType == AuthBearer && bearerToken != "" {
return bearerToken
}
return ""
}
// Authorization returns the authentication type and token from the authorization request header,
// or an empty string if there is none.
func Authorization(c *gin.Context) (authType, authToken string) {
if c == nil {
return "", ""
} else if s := c.GetHeader(Auth); s == "" {
// Ignore.
} else if t := strings.Split(s, " "); len(t) != 2 {
// Ignore.
} else {
return clean.ID(t[0]), clean.ID(t[1])
}
return "", ""
}
// SetAuthorization adds a bearer token authorization header to a request.
func SetAuthorization(r *http.Request, authToken string) {
if authToken != "" {
r.Header.Add(Auth, fmt.Sprintf("%s %s", AuthBearer, authToken))
}
}
// BasicAuth checks the basic authorization header for credentials and returns them if found.
//
// Note that OAuth 2.0 defines basic authentication differently than RFC 7617, however, this
// does not matter as long as only alphanumeric characters are used for client id and secret:
// https://www.scottbrady91.com/oauth/client-authentication#:~:text=OAuth%20Basic%20Authentication
func BasicAuth(c *gin.Context) (username, password, cacheKey string) {
authType, authToken := Authorization(c)
if authType != AuthBasic || authToken == "" {
return "", "", ""
}
auth, err := base64.StdEncoding.DecodeString(authToken)
if err != nil {
return "", "", ""
}
credentials := strings.SplitN(string(auth), ":", 2)
if len(credentials) != 2 {
return "", "", ""
}
cacheKey = fmt.Sprintf("%x", sha1.Sum([]byte(authToken)))
return credentials[0], credentials[1], cacheKey
}

View file

@ -1,4 +1,4 @@
package api
package header
import (
"net/http"
@ -7,8 +7,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/header"
)
func TestAuthToken(t *testing.T) {
@ -33,7 +31,7 @@ func TestAuthToken(t *testing.T) {
}
// Add authorization header.
AddRequestAuthorizationHeader(c.Request, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")
SetAuthorization(c.Request, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")
// Check result.
authToken := AuthToken(c)
@ -50,7 +48,7 @@ func TestAuthToken(t *testing.T) {
}
// Add authorization header.
c.Request.Header.Add(header.AuthToken, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")
c.Request.Header.Add(XAuthToken, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")
// Check result.
authToken := AuthToken(c)
@ -82,7 +80,7 @@ func TestBearerToken(t *testing.T) {
}
// Add authorization header.
AddRequestAuthorizationHeader(c.Request, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")
SetAuthorization(c.Request, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")
// Check result.
token := BearerToken(c)
@ -113,11 +111,11 @@ func TestAuthorization(t *testing.T) {
}
// Add authorization header.
c.Request.Header.Add(header.Authorization, "Bearer 69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")
c.Request.Header.Add(Auth, "Bearer 69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")
// Check result.
authType, authToken := Authorization(c)
assert.Equal(t, "Bearer", authType)
assert.Equal(t, AuthBearer, authType)
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", authToken)
})
}
@ -146,7 +144,7 @@ func TestBasicAuth(t *testing.T) {
}
// Add authorization header.
c.Request.Header.Add(header.Authorization, "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==")
c.Request.Header.Add(Auth, AuthBasic+" QWxhZGRpbjpvcGVuIHNlc2FtZQ==")
// Check result.
user, pass, key := BasicAuth(c)

View file

@ -1,5 +1,5 @@
/*
Package header provides common response header names and default values.
Package header provides abstractions and naming constants for HTTP request and response headers.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.

17
pkg/header/header_test.go Normal file
View file

@ -0,0 +1,17 @@
package header
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestHeader(t *testing.T) {
t.Run("Auth", func(t *testing.T) {
assert.Equal(t, "X-Auth-Token", XAuthToken)
assert.Equal(t, "X-Session-ID", XSessionID)
assert.Equal(t, "Authorization", Auth)
assert.Equal(t, "Basic", AuthBasic)
assert.Equal(t, "Bearer", AuthBearer)
})
}

View file

@ -1,4 +1,4 @@
package api
package header
import (
"github.com/gin-gonic/gin"
@ -11,6 +11,8 @@ func ClientIP(c *gin.Context) (ip string) {
if c == nil {
// Should never happen.
return UnknownIP
} else if c.Request == nil {
return UnknownIP
} else if ip = c.ClientIP(); ip != "" {
return ip
} else if ip = c.RemoteIP(); ip != "" {
@ -26,6 +28,8 @@ func UserAgent(c *gin.Context) string {
if c == nil {
// Should never happen.
return ""
} else if c.Request == nil {
return ""
}
return c.Request.UserAgent()

6
pkg/header/webdav.go Normal file
View file

@ -0,0 +1,6 @@
package header
const (
XFavorite = "X-Favorite"
XModTime = "X-OC-MTime"
)

View file

@ -1,25 +0,0 @@
/*
Package live provides types and abstractions for hybrid photo/video file formats.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package live

View file

@ -21,7 +21,7 @@ Please Note:
## Hybrid Photo/Video Formats
For more information on hybrid photo/video file formats, e.g. Apple Live Photos and Samsung/Google Motion Photos, see [github.com/photoprism/photoprism/tree/develop/pkg/live](https://github.com/photoprism/photoprism/tree/develop/pkg/live) and [docs.photoprism.app/developer-guide/media/live](https://docs.photoprism.app/developer-guide/media/live/).
For more information on hybrid photo/video file formats, e.g. Apple Live Photos and Samsung/Google Motion Photos, see [github.com/photoprism/photoprism/tree/develop/pkg/media](https://github.com/photoprism/photoprism/tree/develop/pkg/media) and [docs.photoprism.app/developer-guide/media/live](https://docs.photoprism.app/developer-guide/media/live/).
## Standard Resolutions