diff --git a/frontend/src/common/api.js b/frontend/src/common/api.js index 8a5a43d6b..110a7b1bd 100644 --- a/frontend/src/common/api.js +++ b/frontend/src/common/api.js @@ -47,7 +47,7 @@ const Api = Axios.create({ baseURL: c.apiUri, headers: { common: { - "X-Session-ID": window.localStorage.getItem("session_id"), + "X-Session-ID": window.localStorage.getItem("authToken"), "X-Client-Uri": c.jsUri, "X-Client-Version": c.version, }, diff --git a/frontend/src/common/session.js b/frontend/src/common/session.js index 6bae79526..97a9c20ed 100644 --- a/frontend/src/common/session.js +++ b/frontend/src/common/session.js @@ -28,8 +28,9 @@ import Event from "pubsub-js"; import User from "model/user"; import Socket from "websocket.js"; -const SessionHeader = "X-Session-ID"; -const PublicID = "234200000000000000000000000000000000000000000000"; +const RequestHeader = "X-Session-ID"; +const PublicSessionID = "a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f"; +const PublicAuthToken = "234200000000000000000000000000000000000000000000"; const LoginPage = "login"; export default class Session { @@ -39,7 +40,7 @@ export default class Session { * @param {object} shared */ constructor(storage, config, shared) { - this.storage_key = "session_storage"; + this.storage_key = "sessionStorage"; this.auth = false; this.config = config; this.user = new User(false); @@ -52,9 +53,12 @@ export default class Session { this.storage = storage; } - // Restore from session storage. - if (this.applyId(this.storage.getItem("session_id"))) { - const dataJson = this.storage.getItem("data"); + // Restore authentication from session storage. + if ( + this.applyAuthToken(this.storage.getItem("authToken")) && + this.applyId(this.storage.getItem("sessionId")) + ) { + const dataJson = this.storage.getItem("sessionData"); if (dataJson !== "undefined") { this.data = JSON.parse(dataJson); } @@ -113,42 +117,91 @@ export default class Session { this.storage = window.localStorage; } - applyId(id) { - if (!id) { + setConfig(values) { + this.config.setValues(values); + } + + setAuthToken(authToken) { + if (authToken) { + this.storage.setItem("authToken", authToken); + if (authToken === PublicAuthToken) { + this.setId(PublicSessionID); + } + } + + return this.applyAuthToken(authToken); + } + + getAuthToken() { + return this.authToken; + } + + hasAuthToken() { + return !!this.authToken; + } + + applyAuthToken(authToken) { + if (!authToken) { this.reset(); return false; } - this.session_id = id; + this.authToken = authToken; - Api.defaults.headers.common[SessionHeader] = id; + Api.defaults.headers.common[RequestHeader] = authToken; return true; } setId(id) { - this.storage.setItem("session_id", id); - return this.applyId(id); - } - - setConfig(values) { - this.config.setValues(values); + this.storage.setItem("sessionId", id); + this.id = id; } getId() { - return this.session_id; + return this.id; } hasId() { - return !!this.session_id; + return !!this.id; } - deleteId() { - this.session_id = null; - this.provider = ""; - this.storage.removeItem("session_id"); + applyId(id) { + if (!id) { + return false; + } - delete Api.defaults.headers.common[SessionHeader]; + this.setId(id); + + return true; + } + + isAuthenticated() { + return this.hasId() && this.hasAuthToken(); + } + + deleteAuthentication() { + this.id = null; + this.authToken = null; + this.provider = ""; + this.storage.removeItem("sessionId"); + this.storage.removeItem("authToken"); + this.storage.removeItem("provider"); + + delete Api.defaults.headers.common[RequestHeader]; + } + + setProvider(provider) { + this.storage.setItem("provider", provider); + this.provider = provider; + } + + getProvider() { + return this.provider; + } + + hasProvider() { + return !!this.provider; } setResp(resp) { @@ -159,15 +212,25 @@ export default class Session { if (resp.data.id) { this.setId(resp.data.id); } - if (resp.data.provider) { - this.provider = resp.data.provider; + + if (resp.data.access_token) { + this.setAuthToken(resp.data.access_token); + } else if (resp.data.id) { + this.setAuthToken(resp.data.id); } + + if (resp.data.provider) { + this.setProvider(resp.data.provider); + } + if (resp.data.config) { this.setConfig(resp.data.config); } + if (resp.data.user) { this.setUser(resp.data.user); } + if (resp.data.data) { this.setData(resp.data.data); } @@ -179,7 +242,7 @@ export default class Session { } this.data = data; - this.storage.setItem("data", JSON.stringify(data)); + this.storage.setItem("sessionData", JSON.stringify(data)); if (data.user) { this.setUser(data.user); @@ -264,7 +327,7 @@ export default class Session { deleteData() { this.data = null; - this.storage.removeItem("data"); + this.storage.removeItem("sessionData"); } deleteUser() { @@ -280,7 +343,7 @@ export default class Session { } reset() { - this.deleteId(); + this.deleteAuthentication(); this.deleteData(); this.deleteUser(); this.deleteClipboard(); @@ -289,7 +352,7 @@ export default class Session { sendClientInfo() { const hasConfig = !!window.__CONFIG__; const clientInfo = { - session: this.getId(), + session: this.getAuthToken(), cssUri: hasConfig ? window.__CONFIG__.cssUri : "", jsUri: hasConfig ? window.__CONFIG__.jsUri : "", version: hasConfig ? window.__CONFIG__.version : "", @@ -327,16 +390,17 @@ export default class Session { } refresh() { - // Refresh session information. + // Check if the authentication is still valid and update the client session data. if (this.config.isPublic()) { - // No authentication in public mode. - this.setId(PublicID); + // Use a static auth token in public mode, as no additional authentication is required. + this.setAuthToken(PublicAuthToken); + this.setId(PublicSessionID); return Api.get("session/" + this.getId()).then((resp) => { this.setResp(resp); return Promise.resolve(); }); - } else if (this.hasId()) { - // Verify authentication. + } else if (this.isAuthenticated()) { + // Check the auth token by fetching the client session data from the API. return Api.get("session/" + this.getId()) .then((resp) => { this.setResp(resp); @@ -350,7 +414,7 @@ export default class Session { return Promise.reject(); }); } else { - // No authentication yet. + // Skip updating session data if client is not authenticated. return Promise.resolve(); } } @@ -367,15 +431,19 @@ export default class Session { } onLogout(noRedirect) { + // Delete all authentication and session data. this.reset(); + + // Perform redirect? if (noRedirect !== true && !this.isLogin()) { window.location = this.config.baseUri + "/"; } + return Promise.resolve(); } logout(noRedirect) { - if (this.hasId()) { + if (this.isAuthenticated()) { return Api.delete("session/" + this.getId()) .then(() => { return this.onLogout(noRedirect); diff --git a/frontend/tests/unit/common/session_test.js b/frontend/tests/unit/common/session_test.js index 1a8e5027d..39a005000 100644 --- a/frontend/tests/unit/common/session_test.js +++ b/frontend/tests/unit/common/session_test.js @@ -14,19 +14,19 @@ describe("common/session", () => { it("should construct session", () => { const storage = new StorageShim(); const session = new Session(storage, config); - assert.equal(session.session_id, null); + assert.equal(session.authToken, null); }); it("should set, get and delete token", () => { const storage = new StorageShim(); const session = new Session(storage, config); assert.equal(session.hasToken("2lbh9x09"), false); - session.setId("999900000000000000000000000000000000000000000000"); - assert.equal(session.session_id, "999900000000000000000000000000000000000000000000"); - const result = session.getId(); + session.setAuthToken("999900000000000000000000000000000000000000000000"); + assert.equal(session.authToken, "999900000000000000000000000000000000000000000000"); + const result = session.getAuthToken(); assert.equal(result, "999900000000000000000000000000000000000000000000"); session.reset(); - assert.equal(session.session_id, null); + assert.equal(session.authToken, null); }); it("should set, get and delete user", () => { @@ -48,9 +48,23 @@ describe("common/session", () => { user, }; + assert.equal(session.hasId(), false); + assert.equal(session.hasAuthToken(), false); + assert.equal(session.isAuthenticated(), false); + assert.equal(session.hasProvider(), false); session.setData(); assert.equal(session.user.DisplayName, ""); session.setData(data); + assert.equal(session.hasId(), false); + assert.equal(session.hasAuthToken(), false); + assert.equal(session.hasProvider(), false); + session.setId("a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f"); + session.setAuthToken("234200000000000000000000000000000000000000000000"); + session.setProvider("public"); + assert.equal(session.hasId(), true); + assert.equal(session.hasAuthToken(), true); + assert.equal(session.isAuthenticated(), true); + assert.equal(session.hasProvider(), true); assert.equal(session.user.DisplayName, "Max Example"); assert.equal(session.user.SuperAdmin, true); assert.equal(session.user.Role, "admin"); @@ -79,6 +93,11 @@ describe("common/session", () => { it("should get user email", () => { const storage = new StorageShim(); const session = new Session(storage, config); + + session.setId("a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f"); + session.setAuthToken("234200000000000000000000000000000000000000000000"); + session.setProvider("public"); + const values = { user: { ID: 5, @@ -88,6 +107,7 @@ describe("common/session", () => { Role: "admin", }, }; + session.setData(values); const result = session.getEmail(); assert.equal(result, "test@test.com"); @@ -121,6 +141,10 @@ describe("common/session", () => { const result = session.getDisplayName(); assert.equal(result, "Max Last"); const values2 = { + id: "a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f", + access_token: "234200000000000000000000000000000000000000000000", + provider: "public", + data: {}, user: { ID: 5, Name: "bar", @@ -221,18 +245,18 @@ describe("common/session", () => { it("should use session storage", () => { const storage = new StorageShim(); const session = new Session(storage, config); - assert.equal(storage.getItem("session_storage"), null); + assert.equal(storage.getItem("sessionStorage"), null); session.useSessionStorage(); - assert.equal(storage.getItem("session_storage"), "true"); + assert.equal(storage.getItem("sessionStorage"), "true"); session.deleteData(); }); it("should use local storage", () => { const storage = new StorageShim(); const session = new Session(storage, config); - assert.equal(storage.getItem("session_storage"), null); + assert.equal(storage.getItem("sessionStorage"), null); session.useLocalStorage(); - assert.equal(storage.getItem("session_storage"), "false"); + assert.equal(storage.getItem("sessionStorage"), "false"); session.deleteData(); }); diff --git a/frontend/tests/unit/fixtures.js b/frontend/tests/unit/fixtures.js index ac0808dd4..732239d69 100644 --- a/frontend/tests/unit/fixtures.js +++ b/frontend/tests/unit/fixtures.js @@ -133,36 +133,46 @@ Mock.onDelete("api/v1/photos/pqbemz8276mhtobh/label/12345").reply( Mock.onPost("api/v1/session").reply( 200, { - id: "999900000000000000000000000000000000000000000000", + id: "5aa770f2a1ef431628d9f17bdf82a0d16865e99d4a1ddd9356e1aabfe6464683", + access_token: "999900000000000000000000000000000000000000000000", + provider: "test", data: { token: "123token" }, user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" }, }, mockHeaders ); -Mock.onGet("api/v1/session/234200000000000000000000000000000000000000000000").reply( +Mock.onGet("api/v1/session/a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f").reply( 200, { - id: "234200000000000000000000000000000000000000000000", + id: "a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f", + access_token: "234200000000000000000000000000000000000000000000", + provider: "public", data: { token: "123token" }, user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" }, }, mockHeaders ); -Mock.onGet("api/v1/session/999900000000000000000000000000000000000000000000").reply( +Mock.onGet("api/v1/session/5aa770f2a1ef431628d9f17bdf82a0d16865e99d4a1ddd9356e1aabfe6464683").reply( 200, { - id: "999900000000000000000000000000000000000000000000", + id: "5aa770f2a1ef431628d9f17bdf82a0d16865e99d4a1ddd9356e1aabfe6464683", + access_token: "999900000000000000000000000000000000000000000000", + provider: "test", data: { token: "123token" }, user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" }, }, mockHeaders ); -Mock.onDelete("api/v1/session/999900000000000000000000000000000000000000000000").reply(200); +Mock.onDelete( + "api/v1/session/5aa770f2a1ef431628d9f17bdf82a0d16865e99d4a1ddd9356e1aabfe6464683" +).reply(200); -Mock.onDelete("api/v1/session/234200000000000000000000000000000000000000000000").reply(200); +Mock.onDelete( + "api/v1/session/a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f" +).reply(200); Mock.onGet("api/v1/settings").reply(200, { download: true, language: "de" }, mockHeaders); Mock.onPost("api/v1/settings").reply(200, { download: true, language: "en" }, mockHeaders); diff --git a/internal/api/api.go b/internal/api/api.go index eca9c09eb..e8c955d2e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,5 +1,5 @@ /* -Package api provides REST API authentication and request handlers. +Package api provides REST-API authentication and request handlers. Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved. diff --git a/internal/api/api_client_config.go b/internal/api/api_client_config.go index 1cd73fee8..47469b0ce 100644 --- a/internal/api/api_client_config.go +++ b/internal/api/api_client_config.go @@ -19,7 +19,7 @@ func UpdateClientConfig() { // GET /api/v1/config func GetClientConfig(router *gin.RouterGroup) { router.GET("/config", func(c *gin.Context) { - s := Session(SessionID(c)) + s := Session(AuthToken(c)) conf := get.Config() if s == nil { diff --git a/internal/api/auth_header.go b/internal/api/api_request_headers.go similarity index 69% rename from internal/api/auth_header.go rename to internal/api/api_request_headers.go index 98e1ea50f..ba0eca2f8 100644 --- a/internal/api/auth_header.go +++ b/internal/api/api_request_headers.go @@ -4,6 +4,7 @@ import ( "crypto/sha1" "encoding/base64" "fmt" + "net/http" "strings" "github.com/gin-gonic/gin" @@ -11,9 +12,9 @@ import ( "github.com/photoprism/photoprism/pkg/clean" ) -// SessionID returns the session ID from the request context, -// or an empty string if there is none. -func SessionID(c *gin.Context) string { +// 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 "" @@ -28,9 +29,9 @@ func SessionID(c *gin.Context) string { return BearerToken(c) } -// BearerToken returns the value of the bearer token header, or an empty string if there is none. +// 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 == "Bearer" && bearerToken != "" { + if authType, bearerToken := Authorization(c); authType == header.BearerAuth && bearerToken != "" { return bearerToken } @@ -40,7 +41,9 @@ func BearerToken(c *gin.Context) string { // 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 s := c.GetHeader(header.Authorization); s == "" { + if c == nil { + return "", "" + } else if s := c.GetHeader(header.Authorization); s == "" { // Ignore. } else if t := strings.Split(s, " "); len(t) != 2 { // Ignore. @@ -51,6 +54,13 @@ func Authorization(c *gin.Context) (authType, authToken string) { 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 @@ -59,7 +69,7 @@ func Authorization(c *gin.Context) (authType, authToken string) { func BasicAuth(c *gin.Context) (username, password, cacheKey string) { authType, authToken := Authorization(c) - if authType != "Basic" || authToken == "" { + if authType != header.BasicAuth || authToken == "" { return "", "", "" } diff --git a/internal/api/api_request_headers_test.go b/internal/api/api_request_headers_test.go new file mode 100644 index 000000000..c9d23f200 --- /dev/null +++ b/internal/api/api_request_headers_test.go @@ -0,0 +1,157 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/server/header" +) + +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. + AddRequestAuthorizationHeader(c.Request, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0") + + // Check result. + authToken := AuthToken(c) + assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", authToken) + bearerToken := BearerToken(c) + assert.Equal(t, authToken, bearerToken) + }) + t.Run("X-Session-ID", 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.SessionID, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0") + + // Check result. + authToken := AuthToken(c) + assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", authToken) + bearerToken := BearerToken(c) + assert.Equal(t, "", bearerToken) + }) +} + +func TestBearerToken(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 := BearerToken(c) + assert.Equal(t, "", token) + }) + t.Run("Found", 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. + AddRequestAuthorizationHeader(c.Request, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0") + + // Check result. + token := BearerToken(c) + assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", token) + }) +} + +func TestAuthorization(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. + authType, authToken := Authorization(c) + assert.Equal(t, "", authType) + assert.Equal(t, "", authToken) + }) + 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. + c.Request.Header.Add(header.Authorization, "Bearer 69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0") + + // Check result. + authType, authToken := Authorization(c) + assert.Equal(t, "Bearer", authType) + assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", authToken) + }) +} + +func TestBasicAuth(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. + user, pass, key := BasicAuth(c) + assert.Equal(t, "", user) + assert.Equal(t, "", pass) + assert.Equal(t, "", key) + }) + t.Run("Found", 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.Authorization, "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==") + + // Check result. + user, pass, key := BasicAuth(c) + assert.Equal(t, "Aladdin", user) + assert.Equal(t, "open sesame", pass) + assert.Equal(t, "0cdb723383eb144043424a4a254461658d887396", key) + }) +} diff --git a/internal/api/response.go b/internal/api/api_response.go similarity index 100% rename from internal/api/response.go rename to internal/api/api_response.go diff --git a/internal/api/headers.go b/internal/api/api_response_headers.go similarity index 100% rename from internal/api/headers.go rename to internal/api/api_response_headers.go diff --git a/internal/api/api_test.go b/internal/api/api_test.go index fb829ccb7..fe840f4e9 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -12,7 +12,9 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/get" + "github.com/photoprism/photoprism/internal/server/header" ) type CloseableResponseRecorder struct { @@ -53,7 +55,7 @@ func NewApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config return app, router, get.Config() } -// Executes an API request with an empty request body. +// PerformRequest runs an API request with an empty request body. // See https://medium.com/@craigchilds94/testing-gin-json-responses-1f258ce3b0b1 func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecorder { req, _ := http.NewRequest(method, path, nil) @@ -63,7 +65,7 @@ func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecor return w } -// Executes an API request with the request body as a string. +// PerformRequestWithBody runs an API request with the request body as a string. func PerformRequestWithBody(r http.Handler, method, path, body string) *httptest.ResponseRecorder { reader := strings.NewReader(body) req, _ := http.NewRequest(method, path, reader) @@ -74,7 +76,7 @@ func PerformRequestWithBody(r http.Handler, method, path, body string) *httptest return w } -// Executes an API request with a stream response. +// PerformRequestWithStream runs an API request with a stream response. func PerformRequestWithStream(r http.Handler, method, path string) *CloseableResponseRecorder { req, _ := http.NewRequest(method, path, nil) w := &CloseableResponseRecorder{httptest.NewRecorder(), make(chan bool, 1)} @@ -83,3 +85,49 @@ func PerformRequestWithStream(r http.Handler, method, path string) *CloseableRes return w } + +// AuthenticateAdmin Register session routes and returns valid SessionId. +// Call this func after registering other routes and before performing other requests. +func AuthenticateAdmin(app *gin.Engine, router *gin.RouterGroup) (authToken string) { + return AuthenticateUser(app, router, "admin", "photoprism") +} + +// AuthenticateUser Register session routes and returns valid SessionId. +// Call this func after registering other routes and before performing other requests. +func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, name string, password string) (authToken string) { + CreateSession(router) + + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", form.AsJson(form.Login{ + UserName: name, + Password: password, + })) + + authToken = r.Header().Get(header.SessionID) + + return +} + +// Performs authenticated API request with empty request body. +func AuthenticatedRequest(r http.Handler, method, path, authToken string) *httptest.ResponseRecorder { + req, _ := http.NewRequest(method, path, nil) + + AddRequestAuthorizationHeader(req, authToken) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + return w +} + +// Performs an authenticated API request containing the request body as a string. +func AuthenticatedRequestWithBody(r http.Handler, method, path, body string, authToken string) *httptest.ResponseRecorder { + reader := strings.NewReader(body) + req, _ := http.NewRequest(method, path, reader) + + AddRequestAuthorizationHeader(req, authToken) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + return w +} diff --git a/internal/api/api_ws.go b/internal/api/api_ws.go index 4e00585ba..14fb5df5f 100644 --- a/internal/api/api_ws.go +++ b/internal/api/api_ws.go @@ -43,9 +43,9 @@ var wsConnection = websocket.Upgrader{ }, } -// clientInfo represents information provided by the WebSocket client. -type clientInfo struct { - SessionID string `json:"session"` +// wsClient represents information about the WebSocket client. +type wsClient struct { + AuthToken string `json:"session"` CssUri string `json:"css"` JsUri string `json:"js"` Version string `json:"version"` @@ -111,18 +111,18 @@ func wsReader(ws *websocket.Conn, writeMutex *sync.Mutex, connId string, conf *c ws.SetPongHandler(func(string) error { _ = ws.SetReadDeadline(time.Now().Add(wsTimeout)); return nil }) for { - _, m, err := ws.ReadMessage() + _, m, readErr := ws.ReadMessage() - if err != nil { + if readErr != nil { break } - var info clientInfo + var info wsClient - if err := json.Unmarshal(m, &info); err != nil { + if jsonErr := json.Unmarshal(m, &info); jsonErr != nil { // Do nothing. } else { - if s := Session(info.SessionID); s != nil { + if s := Session(info.AuthToken); s != nil { wsAuth.mutex.Lock() wsAuth.sid[connId] = s.ID wsAuth.rid[connId] = s.RefID diff --git a/internal/api/api_ws_publish.go b/internal/api/api_ws_publish.go index 23324991d..e5970254f 100644 --- a/internal/api/api_ws_publish.go +++ b/internal/api/api_ws_publish.go @@ -20,7 +20,7 @@ const ( // PublishPhotoEvent publishes updated photo data after changes have been made. func PublishPhotoEvent(ev EntityEvent, uid string, c *gin.Context) { if result, _, err := search.Photos(form.SearchPhotos{UID: uid, Merged: true}); err != nil { - event.AuditErr([]string{ClientIP(c), "session %s", "%s photo %s", "%s"}, SessionID(c), string(ev), uid, err) + event.AuditErr([]string{ClientIP(c), "session %s", "%s photo %s", "%s"}, AuthToken(c), string(ev), uid, err) } else { event.PublishEntities("photos", string(ev), result) } @@ -30,7 +30,7 @@ func PublishPhotoEvent(ev EntityEvent, uid string, c *gin.Context) { func PublishAlbumEvent(ev EntityEvent, uid string, c *gin.Context) { f := form.SearchAlbums{UID: uid} if result, err := search.Albums(f); err != nil { - event.AuditErr([]string{ClientIP(c), "session %s", "%s album %s", "%s"}, SessionID(c), string(ev), uid, err) + event.AuditErr([]string{ClientIP(c), "session %s", "%s album %s", "%s"}, AuthToken(c), string(ev), uid, err) } else { event.PublishEntities("albums", string(ev), result) } @@ -40,7 +40,7 @@ func PublishAlbumEvent(ev EntityEvent, uid string, c *gin.Context) { func PublishLabelEvent(ev EntityEvent, uid string, c *gin.Context) { f := form.SearchLabels{UID: uid} if result, err := search.Labels(f); err != nil { - event.AuditErr([]string{ClientIP(c), "session %s", "%s label %s", "%s"}, SessionID(c), string(ev), uid, err) + event.AuditErr([]string{ClientIP(c), "session %s", "%s label %s", "%s"}, AuthToken(c), string(ev), uid, err) } else { event.PublishEntities("labels", string(ev), result) } @@ -50,7 +50,7 @@ func PublishLabelEvent(ev EntityEvent, uid string, c *gin.Context) { func PublishSubjectEvent(ev EntityEvent, uid string, c *gin.Context) { f := form.SearchSubjects{UID: uid} if result, err := search.Subjects(f); err != nil { - event.AuditErr([]string{ClientIP(c), "session %s", "%s subject %s", "%s"}, SessionID(c), string(ev), uid, err) + event.AuditErr([]string{ClientIP(c), "session %s", "%s subject %s", "%s"}, AuthToken(c), string(ev), uid, err) } else { event.PublishEntities("subjects", string(ev), result) } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go deleted file mode 100644 index 0b148b004..000000000 --- a/internal/api/auth_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/internal/server/header" -) - -// AuthenticateAdmin Register session routes and returns valid SessionId. -// Call this func after registering other routes and before performing other requests. -func AuthenticateAdmin(app *gin.Engine, router *gin.RouterGroup) (sessId string) { - return AuthenticateUser(app, router, "admin", "photoprism") -} - -// AuthenticateUser Register session routes and returns valid SessionId. -// Call this func after registering other routes and before performing other requests. -func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, name string, password string) (sessId string) { - CreateSession(router) - - r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", form.AsJson(form.Login{ - UserName: name, - Password: password, - })) - - sessId = r.Header().Get(header.SessionID) - - return -} - -// Performs authenticated API request with empty request body. -func AuthenticatedRequest(r http.Handler, method, path, sess string) *httptest.ResponseRecorder { - req, _ := http.NewRequest(method, path, nil) - - if sess != "" { - req.Header.Add(header.SessionID, sess) - } - - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - return w -} - -// Performs an authenticated API request containing the request body as a string. -func AuthenticatedRequestWithBody(r http.Handler, method, path, body string, sess string) *httptest.ResponseRecorder { - reader := strings.NewReader(body) - req, _ := http.NewRequest(method, path, reader) - - if sess != "" { - req.Header.Add(header.SessionID, sess) - } - - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - return w -} diff --git a/internal/api/session.go b/internal/api/session.go index 41217c4b1..2cc95f80a 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -1,25 +1,65 @@ package api import ( + "github.com/gin-gonic/gin" + + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/get" + "github.com/photoprism/photoprism/pkg/rnd" ) -// Session finds the client session for the given ID or returns nil otherwise. -func Session(id string) *entity.Session { - // Skip authentication if app is running in public mode. +// Session finds the client session for the specified +// auth token, or returns nil if not found. +func Session(authToken string) *entity.Session { + // Skip authentication when running in public mode. if get.Config().Public() { return get.Session().Public() - } else if id == "" { + } else if !rnd.IsAuthToken(authToken) { return nil } - // Find session or otherwise return nil. - s, err := get.Session().Get(id) - - if err != nil { + // Find the session based on the hashed auth + // token used as id, or return nil otherwise. + if s, err := get.Session().Get(rnd.SessionID(authToken)); err != nil { return nil + } else { + return s + } +} + +// SessionResponse returns authentication response data based on the session and client config. +func SessionResponse(authToken string, sess *entity.Session, conf config.ClientConfig) gin.H { + if authToken == "" { + return gin.H{ + "status": "ok", + "id": sess.ID, + "expires_in": sess.ExpiresIn(), + "provider": sess.Provider().String(), + "user": sess.User(), + "data": sess.Data(), + "config": conf, + } + } else { + return gin.H{ + "status": "ok", + "id": sess.ID, + "access_token": authToken, + "token_type": sess.AuthTokenType(), + "expires_in": sess.ExpiresIn(), + "provider": sess.Provider().String(), + "user": sess.User(), + "data": sess.Data(), + "config": conf, + } + } +} + +// SessionDeleteResponse returns a confirmation response for deleted sessions. +func SessionDeleteResponse(authToken string) gin.H { + if authToken == "" { + return gin.H{"status": "ok"} + } else { + return gin.H{"status": "ok", "id": authToken, "access_token": authToken} } - - return s } diff --git a/internal/api/auth.go b/internal/api/session_acl.go similarity index 98% rename from internal/api/auth.go rename to internal/api/session_acl.go index 511e93884..609a8b9ba 100644 --- a/internal/api/auth.go +++ b/internal/api/session_acl.go @@ -17,10 +17,10 @@ func Auth(c *gin.Context, resource acl.Resource, grant acl.Permission) *entity.S 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) - sid := SessionID(c) + authToken := AuthToken(c) // Find active session to perform authorization check or deny if no session was found. - if s = Session(sid); s == nil { + if s = Session(authToken); s == nil { event.AuditWarn([]string{ip, "unauthenticated", "%s %s", "denied"}, grants.String(), string(resource)) return entity.SessionStatusUnauthorized() } else { diff --git a/internal/api/session_acl_test.go b/internal/api/session_acl_test.go new file mode 100644 index 000000000..428d239e9 --- /dev/null +++ b/internal/api/session_acl_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/acl" + "github.com/photoprism/photoprism/internal/session" +) + +func TestAuth(t *testing.T) { + t.Run("Public", 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. + AddRequestAuthorizationHeader(c.Request, session.PublicAuthToken) + + // Check auth token. + authToken := AuthToken(c) + assert.Equal(t, session.PublicAuthToken, authToken) + + // Check successful authorization in public mode. + s := Auth(c, acl.ResourceFiles, acl.ActionUpdate) + assert.NotNil(t, s) + assert.Equal(t, "admin", s.Username()) + assert.Equal(t, session.PublicID, s.ID) + assert.Equal(t, http.StatusOK, s.HttpStatus()) + assert.False(t, s.Abort(c)) + + // Check failed authorization in public mode. + s = Auth(c, acl.ResourceUsers, acl.ActionUpload) + assert.NotNil(t, s) + assert.Equal(t, "", s.Username()) + assert.Equal(t, "", s.ID) + assert.Equal(t, http.StatusForbidden, s.HttpStatus()) + assert.True(t, s.Abort(c)) + }) +} + +func TestAuthAny(t *testing.T) { + t.Run("Public", 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. + AddRequestAuthorizationHeader(c.Request, session.PublicAuthToken) + + // Check auth token. + authToken := AuthToken(c) + assert.Equal(t, session.PublicAuthToken, authToken) + + // Check successful authorization in public mode. + s := AuthAny(c, acl.ResourceFiles, acl.Permissions{acl.ActionUpdate}) + assert.NotNil(t, s) + assert.Equal(t, "admin", s.Username()) + assert.Equal(t, session.PublicID, s.ID) + assert.Equal(t, http.StatusOK, s.HttpStatus()) + assert.False(t, s.Abort(c)) + + // Check failed authorization in public mode. + s = AuthAny(c, acl.ResourceUsers, acl.Permissions{acl.ActionUpload}) + assert.NotNil(t, s) + assert.Equal(t, "", s.Username()) + assert.Equal(t, "", s.ID) + assert.Equal(t, http.StatusForbidden, s.HttpStatus()) + assert.True(t, s.Abort(c)) + + // Check successful authorization with multiple actions in public mode. + s = AuthAny(c, acl.ResourceUsers, acl.Permissions{acl.ActionUpload, acl.ActionView}) + assert.NotNil(t, s) + assert.Equal(t, "admin", s.Username()) + assert.Equal(t, session.PublicID, s.ID) + assert.Equal(t, http.StatusOK, s.HttpStatus()) + assert.False(t, s.Abort(c)) + }) +} diff --git a/internal/api/session_create.go b/internal/api/session_create.go index 31b6f3c53..85011eac4 100644 --- a/internal/api/session_create.go +++ b/internal/api/session_create.go @@ -31,15 +31,12 @@ func CreateSession(router *gin.RouterGroup) { // Skip authentication if app is running in public mode. if conf.Public() { sess := get.Session().Public() - data := gin.H{ - "status": "ok", - "id": sess.ID, - "provider": sess.AuthProvider, - "user": sess.User(), - "data": sess.Data(), - "config": conf.ClientPublic(), - } - c.JSON(http.StatusOK, data) + + // Response includes admin account data, session data, and client config values. + response := SessionResponse(sess.AuthToken(), sess, conf.ClientPublic()) + + // Return JSON response. + c.JSON(http.StatusOK, response) return } @@ -53,7 +50,7 @@ func CreateSession(router *gin.RouterGroup) { var isNew bool // Find existing session, if any. - if s := Session(SessionID(c)); s != nil { + if s := Session(AuthToken(c)); s != nil { // Update existing session. sess = s } else { @@ -80,22 +77,12 @@ func CreateSession(router *gin.RouterGroup) { } // Add session id to response headers. - AddSessionHeader(c, sess.ID) + AddSessionHeader(c, sess.AuthToken()) - // Get config values for the UI. - clientConfig := conf.ClientSession(sess) + // Response includes user data, session data, and client config values. + response := SessionResponse(sess.AuthToken(), sess, conf.ClientSession(sess)) - // User information, session data, and client config values. - data := gin.H{ - "status": "ok", - "id": sess.ID, - "provider": sess.AuthProvider, - "user": sess.User(), - "data": sess.Data(), - "config": clientConfig, - } - - // Send JSON response. - c.JSON(sess.HttpStatus(), data) + // Return JSON response. + c.JSON(sess.HttpStatus(), response) }) } diff --git a/internal/api/session_delete.go b/internal/api/session_delete.go index 81657271e..fe9dccbae 100644 --- a/internal/api/session_delete.go +++ b/internal/api/session_delete.go @@ -9,6 +9,7 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/get" + "github.com/photoprism/photoprism/internal/session" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" ) @@ -20,38 +21,54 @@ func DeleteSession(router *gin.RouterGroup) { router.DELETE("/session/:id", func(c *gin.Context) { id := clean.ID(c.Param("id")) - // Abort if ID is missing. + // Abort if authentication token is missing or empty. if id == "" { AbortBadRequest(c) return } else if get.Config().Public() { - c.JSON(http.StatusOK, gin.H{"status": "authentication disabled", "id": id}) + c.JSON(http.StatusOK, gin.H{"status": "running in public mode", "id": session.PublicAuthToken}) return } - // Find session by reference ID. - if !rnd.IsRefID(id) { - // Do nothing. - } else if s := Session(SessionID(c)); s == nil { - entity.SessionStatusUnauthorized().Abort(c) - return - } else if !acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) { - s.Abort(c) - return - } else if ref := entity.FindSessionByRefID(id); ref == nil { - AbortNotFound(c) - return + // Only admins may delete other sessions by reference id. + if rnd.IsRefID(id) { + if s := Session(AuthToken(c)); s == nil { + entity.SessionStatusUnauthorized().Abort(c) + return + } else if s.Abort(c) { + return + } else if !acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) { + s.Abort(c) + return + } else if ref := entity.FindSessionByRefID(id); ref == nil { + AbortNotFound(c) + return + } else { + id = ref.ID + } } else { - id = ref.ID + if s := Session(AuthToken(c)); s == nil { + entity.SessionStatusUnauthorized().Abort(c) + return + } else if s.Abort(c) { + return + } else if s.ID != id { + entity.SessionStatusForbidden().Abort(c) + return + } } - // Delete session by ID. + // Delete session. if err := get.Session().Delete(id); err != nil { event.AuditErr([]string{ClientIP(c), "session %s"}, err) } else { event.AuditDebug([]string{ClientIP(c), "session deleted"}) } - c.JSON(http.StatusOK, gin.H{"status": "ok", "id": id}) + // Response includes the auth token for confirmation. + response := SessionDeleteResponse(id) + + // Return JSON response. + c.JSON(http.StatusOK, response) }) } diff --git a/internal/api/session_get.go b/internal/api/session_get.go index ecd55d97f..d48badeca 100644 --- a/internal/api/session_get.go +++ b/internal/api/session_get.go @@ -17,22 +17,24 @@ func GetSession(router *gin.RouterGroup) { router.GET("/session/:id", func(c *gin.Context) { id := clean.ID(c.Param("id")) + // Check authentication token. if id == "" { + // Abort if authentication token is missing or empty. AbortBadRequest(c) return - } else if id != SessionID(c) { - AbortForbidden(c) - return } conf := get.Config() + authToken := AuthToken(c) // Skip authentication if app is running in public mode. var sess *entity.Session if conf.Public() { sess = get.Session().Public() + id = sess.ID + authToken = sess.AuthToken() } else { - sess = Session(id) + sess = Session(authToken) } switch { @@ -42,7 +44,7 @@ func GetSession(router *gin.RouterGroup) { case sess.Expired(), sess.ID == "": AbortUnauthorized(c) return - case sess.Invalid(): + case sess.Invalid(), sess.ID != id && !conf.Public(): AbortForbidden(c) return } @@ -51,18 +53,12 @@ func GetSession(router *gin.RouterGroup) { sess.RefreshUser() // Add session id to response headers. - AddSessionHeader(c, sess.ID) + AddSessionHeader(c, authToken) - // Send JSON response with user information, session data, and client config values. - data := gin.H{ - "status": "ok", - "id": sess.ID, - "provider": sess.AuthProvider, - "user": sess.User(), - "data": sess.Data(), - "config": get.Config().ClientSession(sess), - } + // Response includes user data, session data, and client config values. + response := SessionResponse(authToken, sess, get.Config().ClientSession(sess)) - c.JSON(http.StatusOK, data) + // Return JSON response. + c.JSON(http.StatusOK, response) }) } diff --git a/internal/api/oauth.go b/internal/api/session_oauth.go similarity index 91% rename from internal/api/oauth.go rename to internal/api/session_oauth.go index 1bd0f241c..5f03c1db3 100644 --- a/internal/api/oauth.go +++ b/internal/api/session_oauth.go @@ -14,8 +14,8 @@ import ( "github.com/photoprism/photoprism/internal/server/limiter" ) -// CreateOauthToken creates a new access token and returns it as JSON -// if the client's credentials have been successfully validated. +// CreateOauthToken creates a new access token for clients that +// authenticate with valid OAuth2 client credentials. // // POST /api/v1/oauth/token func CreateOauthToken(router *gin.RouterGroup) { @@ -60,7 +60,7 @@ func CreateOauthToken(router *gin.RouterGroup) { limiter.Login.Reserve(clientIP) return } else if !client.AuthEnabled { - event.AuditWarn([]string{clientIP, "client %s", "create access token", "authentication disabled"}, f.ClientID) + event.AuditWarn([]string{clientIP, "client %s", "create access token", "running in public mode"}, f.ClientID) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)}) return } else if client.AuthMethod != authn.MethodOAuth2.String() { @@ -92,13 +92,14 @@ func CreateOauthToken(router *gin.RouterGroup) { event.AuditInfo([]string{clientIP, "client %s", "session %s", "access token created"}, f.ClientID, sess.RefID) } - // Return access token. + // Response includes access token, token type, and token lifetime. data := gin.H{ - "access_token": sess.ID, - "token_type": "Bearer", + "access_token": sess.AuthToken(), + "token_type": sess.AuthTokenType(), "expires_in": sess.ExpiresIn(), } + // Return JSON response. c.JSON(http.StatusOK, data) }) } diff --git a/internal/api/oauth_test.go b/internal/api/session_oauth_test.go similarity index 100% rename from internal/api/oauth_test.go rename to internal/api/session_oauth_test.go diff --git a/internal/api/session_test.go b/internal/api/session_test.go index 30136778e..bfa1b19ad 100644 --- a/internal/api/session_test.go +++ b/internal/api/session_test.go @@ -9,21 +9,55 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/internal/get" "github.com/photoprism/photoprism/internal/i18n" - "github.com/photoprism/photoprism/internal/session" + "github.com/photoprism/photoprism/pkg/rnd" ) -func TestSessionID(t *testing.T) { - t.Run("NoContext", func(t *testing.T) { - result := SessionID(nil) - assert.Equal(t, "", result) - }) -} - func TestSession(t *testing.T) { t.Run("Public", func(t *testing.T) { - assert.Equal(t, session.Public, Session("")) - assert.Equal(t, session.Public, Session("638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0")) + sess := get.Session().Public() + assert.Equal(t, sess, Session("")) + assert.Equal(t, sess, Session("638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0")) + }) +} + +func TestSessionResponse(t *testing.T) { + t.Run("Public", func(t *testing.T) { + sess := get.Session().Public() + conf := get.Config().ClientSession(sess) + + // Create response in public mode. + result := SessionResponse(sess.AuthToken(), sess, conf) + + // Check response. + assert.Equal(t, "ok", result["status"]) + assert.Equal(t, sess.ID, result["id"]) + assert.Equal(t, sess.AuthToken(), result["access_token"]) + assert.Equal(t, sess.AuthTokenType(), result["token_type"]) + assert.Equal(t, sess.ExpiresIn(), result["expires_in"]) + assert.Equal(t, sess.Provider().String(), result["provider"]) + assert.Equal(t, sess.User(), result["user"]) + assert.Equal(t, sess.Data(), result["data"]) + assert.Equal(t, conf, result["config"]) + }) + t.Run("NoAuthToken", func(t *testing.T) { + sess := get.Session().Public() + conf := get.Config().ClientSession(sess) + + // Create response without auth token. + result := SessionResponse("", sess, conf) + + // Check response. + assert.Equal(t, "ok", result["status"]) + assert.Equal(t, sess.ID, result["id"]) + assert.Nil(t, result["access_token"]) + assert.Nil(t, result["token_type"]) + assert.Equal(t, sess.ExpiresIn(), result["expires_in"]) + assert.Equal(t, sess.Provider().String(), result["provider"]) + assert.Equal(t, sess.User(), result["user"]) + assert.Equal(t, sess.Data(), result["data"]) + assert.Equal(t, conf, result["config"]) }) } @@ -34,6 +68,7 @@ func TestCreateSession(t *testing.T) { defer conf.SetAuthMode(config.AuthModePublic) CreateSession(router) + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism"}`) log.Debugf("BODY: %s", r.Body.String()) val2 := gjson.Get(r.Body.String(), "user.Name") @@ -46,6 +81,7 @@ func TestCreateSession(t *testing.T) { defer conf.SetAuthMode(config.AuthModePublic) CreateSession(router) + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": 123, "password": "xxx"}`) assert.Equal(t, http.StatusBadRequest, r.Code) }) @@ -55,6 +91,7 @@ func TestCreateSession(t *testing.T) { defer conf.SetAuthMode(config.AuthModePublic) CreateSession(router) + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism", "token": "xxx"}`) assert.Equal(t, http.StatusNotFound, r.Code) }) @@ -63,9 +100,9 @@ func TestCreateSession(t *testing.T) { conf.SetAuthMode(config.AuthModePasswd) defer conf.SetAuthMode(config.AuthModePublic) - // CreateSession(router) - sessId := AuthenticateUser(app, router, "alice", "Alice123!") - r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "xxx"}`, sessId) + authToken := AuthenticateUser(app, router, "alice", "Alice123!") + + r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "xxx"}`, authToken) assert.Equal(t, http.StatusNotFound, r.Code) }) t.Run("VisitorInvalidToken", func(t *testing.T) { @@ -74,6 +111,7 @@ func TestCreateSession(t *testing.T) { defer conf.SetAuthMode(config.AuthModePublic) CreateSession(router) + r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "xxx"}`, "345346") assert.Equal(t, http.StatusNotFound, r.Code) }) @@ -82,13 +120,16 @@ func TestCreateSession(t *testing.T) { conf.SetAuthMode(config.AuthModePasswd) defer conf.SetAuthMode(config.AuthModePublic) - sessId := AuthenticateUser(app, router, "alice", "Alice123!") - r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "1jxf3jfn2k"}`, sessId) + authToken := AuthenticateUser(app, router, "alice", "Alice123!") + + r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "1jxf3jfn2k"}`, authToken) assert.Equal(t, http.StatusOK, r.Code) }) t.Run("PublicValidToken", func(t *testing.T) { app, router, _ := NewApiTest() + CreateSession(router) + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism", "token": "1jxf3jfn2k"}`) assert.Equal(t, http.StatusOK, r.Code) }) @@ -128,6 +169,7 @@ func TestCreateSession(t *testing.T) { defer conf.SetAuthMode(config.AuthModePublic) CreateSession(router) + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "bob", "password": "Bobbob123!"}`) userEmail := gjson.Get(r.Body.String(), "user.Email") userName := gjson.Get(r.Body.String(), "user.Name") @@ -141,6 +183,7 @@ func TestCreateSession(t *testing.T) { defer conf.SetAuthMode(config.AuthModePublic) CreateSession(router) + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "bob", "password": "helloworld"}`) val := gjson.Get(r.Body.String(), "error") assert.Equal(t, i18n.Msg(i18n.ErrInvalidCredentials), val.String()) @@ -155,10 +198,10 @@ func TestGetSession(t *testing.T) { defer conf.SetAuthMode(config.AuthModePublic) GetSession(router) + authToken := AuthenticateAdmin(app, router) - sessId := AuthenticateAdmin(app, router) - r := PerformRequest(app, http.MethodGet, "/api/v1/session/"+sessId) - assert.Equal(t, http.StatusForbidden, r.Code) + r := PerformRequest(app, http.MethodGet, "/api/v1/session/"+rnd.SessionID(authToken)) + assert.Equal(t, http.StatusUnauthorized, r.Code) }) t.Run("AdminAuthenticatedRequest", func(t *testing.T) { app, router, conf := NewApiTest() @@ -166,9 +209,10 @@ func TestGetSession(t *testing.T) { defer conf.SetAuthMode(config.AuthModePublic) GetSession(router) + authToken := AuthenticateAdmin(app, router) - sessId := AuthenticateAdmin(app, router) - r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/session/"+sessId, sessId) + t.Logf("Session ID: %s", authToken) + r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/session/"+rnd.SessionID(authToken), authToken) assert.Equal(t, http.StatusOK, r.Code) }) } @@ -180,10 +224,12 @@ func TestDeleteSession(t *testing.T) { defer conf.SetAuthMode(config.AuthModePublic) DeleteSession(router) + authToken := AuthenticateAdmin(app, router) - sessId := AuthenticateAdmin(app, router) + // f9ae12e95a01bcc7faae6497124cd721eaf13c1dad301dbc + t.Logf("authToken: %s", authToken) - r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+sessId) + r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(authToken), authToken) assert.Equal(t, http.StatusOK, r.Code) }) t.Run("AdminAuthenticatedRequest", func(t *testing.T) { @@ -192,10 +238,9 @@ func TestDeleteSession(t *testing.T) { defer conf.SetAuthMode(config.AuthModePublic) DeleteSession(router) + authToken := AuthenticateAdmin(app, router) - sessId := AuthenticateAdmin(app, router) - - r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+sessId, sessId) + r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(authToken), authToken) assert.Equal(t, http.StatusOK, r.Code) }) t.Run("UserWithoutAuthentication", func(t *testing.T) { @@ -204,11 +249,10 @@ func TestDeleteSession(t *testing.T) { defer conf.SetAuthMode(config.AuthModePublic) DeleteSession(router) + bobToken := "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1" - sessId := AuthenticateUser(app, router, "alice", "Alice123!") - - r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+sessId) - assert.Equal(t, http.StatusOK, r.Code) + r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(bobToken)) + assert.Equal(t, http.StatusUnauthorized, r.Code) }) t.Run("UserAuthenticatedRequest", func(t *testing.T) { app, router, conf := NewApiTest() @@ -216,22 +260,45 @@ func TestDeleteSession(t *testing.T) { defer conf.SetAuthMode(config.AuthModePublic) DeleteSession(router) + authToken := AuthenticateUser(app, router, "alice", "Alice123!") - sessId := AuthenticateUser(app, router, "alice", "Alice123!") - - r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+sessId, sessId) + r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(authToken), authToken) assert.Equal(t, http.StatusOK, r.Code) }) + t.Run("AliceSessionAsBob", func(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + + DeleteSession(router) + bobToken := AuthenticateUser(app, router, "bob", "Bobbob123!") + aliceToken := "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0" + + r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(aliceToken), bobToken) + assert.Equal(t, http.StatusForbidden, r.Code) + }) + t.Run("BobSessionAsAlice", func(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + + DeleteSession(router) + aliceToken := AuthenticateUser(app, router, "alice", "Alice123!") + bobToken := "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1" + + r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(bobToken), aliceToken) + assert.Equal(t, http.StatusForbidden, r.Code) + }) t.Run("InvalidSession", func(t *testing.T) { app, router, conf := NewApiTest() conf.SetAuthMode(config.AuthModePasswd) defer conf.SetAuthMode(config.AuthModePublic) - sessId := "638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0" - DeleteSession(router) + authToken := AuthenticateUser(app, router, "alice", "Alice123!") + deleteToken := "638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0" - r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+sessId) - assert.Equal(t, http.StatusOK, r.Code) + r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(deleteToken), authToken) + assert.Equal(t, http.StatusForbidden, r.Code) }) } diff --git a/internal/commands/auth_add.go b/internal/commands/auth_add.go index d1dd949e8..c19045022 100644 --- a/internal/commands/auth_add.go +++ b/internal/commands/auth_add.go @@ -88,7 +88,7 @@ func authAddAction(ctx *cli.Context) error { fmt.Printf("\nPLEASE WRITE DOWN THE FOLLOWING RANDOMLY GENERATED PERSONAL ACCESS TOKEN, AS YOU WILL NOT BE ABLE TO SEE IT AGAIN:\n") } - result := report.Credentials("Access Token", sess.ID, "Authorization Scope", sess.Scope()) + result := report.Credentials("Access Token", sess.AuthToken(), "Authorization Scope", sess.Scope()) fmt.Printf("\n%s\n", result) } diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go index 9bb10119b..fb311bf70 100644 --- a/internal/entity/auth_session.go +++ b/internal/entity/auth_session.go @@ -12,6 +12,7 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/i18n" + "github.com/photoprism/photoprism/internal/server/header" "github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/list" @@ -35,6 +36,7 @@ type Session struct { UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID" yaml:"UserUID,omitempty"` UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"` user *User `gorm:"-"` + authToken string `gorm:"-"` AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"` AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,omitempty"` AuthDomain string `gorm:"type:VARBINARY(255);default:'';" json:"AuthDomain" yaml:"AuthDomain,omitempty"` @@ -66,17 +68,12 @@ func (Session) TableName() string { // NewSession creates a new session using the maxAge and timeout in seconds. func NewSession(lifetime, timeout int64) (m *Session) { - created := TimeStamp() + m = &Session{} - m = &Session{ - ID: rnd.SessionID(), - RefID: rnd.RefID(SessionPrefix), - CreatedAt: created, - UpdatedAt: created, - } + m.Regenerate() if lifetime > 0 { - m.SessExpires = created.Unix() + lifetime + m.SessExpires = TimeStamp().Unix() + lifetime } if timeout > 0 { @@ -142,9 +139,27 @@ func FindSessionByRefID(refId string) *Session { return m } -// RegenerateID regenerated the random session ID. -func (m *Session) RegenerateID() *Session { - if m.ID == "" { +// AuthToken returns the secret client authentication token. +func (m *Session) AuthToken() string { + return m.authToken +} + +// SetAuthToken sets a custom authentication token. +func (m *Session) SetAuthToken(authToken string) *Session { + m.authToken = authToken + m.ID = rnd.SessionID(authToken) + + return m +} + +// AuthTokenType returns the authentication token type. +func (m *Session) AuthTokenType() string { + return header.BearerAuth +} + +// Regenerate (re-)initializes the session with a random auth token, ID, and RefID. +func (m *Session) Regenerate() *Session { + if !rnd.IsSessionID(m.ID) { // Do not delete the old session if no ID is set yet. } else if err := m.Delete(); err != nil { event.AuditErr([]string{m.IP(), "session %s", "failed to delete", "%s"}, m.RefID, err) @@ -154,7 +169,7 @@ func (m *Session) RegenerateID() *Session { generated := TimeStamp() - m.ID = rnd.SessionID() + m.SetAuthToken(rnd.AuthToken()) m.RefID = rnd.RefID(SessionPrefix) m.CreatedAt = generated m.UpdatedAt = generated @@ -222,7 +237,7 @@ func (m *Session) BeforeCreate(scope *gorm.Scope) error { return nil } - m.ID = rnd.SessionID() + m.Regenerate() return scope.SetColumn("ID", m.ID) } diff --git a/internal/entity/auth_session_cache.go b/internal/entity/auth_session_cache.go index 32d3d25c1..d34975a8d 100644 --- a/internal/entity/auth_session_cache.go +++ b/internal/entity/auth_session_cache.go @@ -15,13 +15,17 @@ import ( var sessionCacheExpiration = 15 * time.Minute var sessionCache = gc.New(sessionCacheExpiration, 5*time.Minute) -// FindSession returns an existing session or nil if not found. +// FindSessionByAuthToken finds a session based on the auth token string or returns nil if it does not exist. +func FindSessionByAuthToken(token string) (*Session, error) { + return FindSession(rnd.SessionID(token)) +} + +// FindSession finds a session based on the id string or returns nil if it does not exist. func FindSession(id string) (*Session, error) { found := &Session{} - // Valid id? if !rnd.IsSessionID(id) { - return found, fmt.Errorf("id %s is invalid", clean.LogQuote(id)) + return found, fmt.Errorf("invalid session id") } // Find the session in the cache with a fallback to the database. diff --git a/internal/entity/auth_session_cache_test.go b/internal/entity/auth_session_cache_test.go index 54b7e700f..968105f5e 100644 --- a/internal/entity/auth_session_cache_test.go +++ b/internal/entity/auth_session_cache_test.go @@ -4,9 +4,9 @@ import ( "testing" "time" - "github.com/photoprism/photoprism/pkg/rnd" - "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/pkg/rnd" ) func TestFlushSessionCache(t *testing.T) { @@ -15,6 +15,72 @@ func TestFlushSessionCache(t *testing.T) { }) } +func TestFindSessionByAuthToken(t *testing.T) { + t.Run("EmptyID", func(t *testing.T) { + if _, err := FindSessionByAuthToken(""); err == nil { + t.Fatal("error expected") + } + }) + t.Run("InvalidID", func(t *testing.T) { + if _, err := FindSessionByAuthToken("as6sg6bxpogaaba7"); err == nil { + t.Fatal("error expected") + } + }) + t.Run("NotFound", func(t *testing.T) { + if _, err := FindSessionByAuthToken(rnd.AuthToken()); err == nil { + t.Fatal("error expected") + } + }) + t.Run("Alice", func(t *testing.T) { + if result, err := FindSessionByAuthToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"); err != nil { + t.Fatal(err) + } else { + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"), result.ID) + assert.Equal(t, UserFixtures.Pointer("alice").UserUID, result.UserUID) + assert.Equal(t, UserFixtures.Pointer("alice").UserName, result.UserName) + } + if cached, err := FindSessionByAuthToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"); err != nil { + t.Fatal(err) + } else { + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"), cached.ID) + assert.Equal(t, UserFixtures.Pointer("alice").UserUID, cached.UserUID) + assert.Equal(t, UserFixtures.Pointer("alice").UserName, cached.UserName) + } + }) + t.Run("Bob", func(t *testing.T) { + if result, err := FindSessionByAuthToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"); err != nil { + t.Fatal(err) + } else { + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"), result.ID) + assert.Equal(t, UserFixtures.Pointer("bob").UserUID, result.UserUID) + assert.Equal(t, UserFixtures.Pointer("bob").UserName, result.UserName) + } + if cached, err := FindSessionByAuthToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"); err != nil { + t.Fatal(err) + } else { + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"), cached.ID) + assert.Equal(t, UserFixtures.Pointer("bob").UserUID, cached.UserUID) + assert.Equal(t, UserFixtures.Pointer("bob").UserName, cached.UserName) + } + }) + t.Run("Visitor", func(t *testing.T) { + if result, err := FindSessionByAuthToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"); err != nil { + t.Fatal(err) + } else { + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"), result.ID) + assert.Equal(t, Visitor.UserUID, result.UserUID) + assert.Equal(t, Visitor.UserName, result.UserName) + } + if cached, err := FindSessionByAuthToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"); err != nil { + t.Fatal(err) + } else { + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"), cached.ID) + assert.Equal(t, Visitor.UserUID, cached.UserUID) + assert.Equal(t, Visitor.UserName, cached.UserName) + } + }) +} + func TestFindSession(t *testing.T) { t.Run("EmptyID", func(t *testing.T) { if _, err := FindSession(""); err == nil { @@ -27,54 +93,54 @@ func TestFindSession(t *testing.T) { } }) t.Run("NotFound", func(t *testing.T) { - if _, err := FindSession(rnd.SessionID()); err == nil { + if _, err := FindSession(rnd.AuthToken()); err == nil { t.Fatal("error expected") } }) t.Run("Alice", func(t *testing.T) { - if result, err := FindSession("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"); err != nil { + if result, err := FindSession(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")); err != nil { t.Fatal(err) } else { - assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", result.ID) + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"), result.ID) assert.Equal(t, UserFixtures.Pointer("alice").UserUID, result.UserUID) assert.Equal(t, UserFixtures.Pointer("alice").UserName, result.UserName) } - if cached, err := FindSession("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"); err != nil { + if cached, err := FindSession(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")); err != nil { t.Fatal(err) } else { - assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", cached.ID) + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"), cached.ID) assert.Equal(t, UserFixtures.Pointer("alice").UserUID, cached.UserUID) assert.Equal(t, UserFixtures.Pointer("alice").UserName, cached.UserName) } }) t.Run("Bob", func(t *testing.T) { - if result, err := FindSession("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"); err != nil { + if result, err := FindSession(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1")); err != nil { t.Fatal(err) } else { - assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1", result.ID) + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"), result.ID) assert.Equal(t, UserFixtures.Pointer("bob").UserUID, result.UserUID) assert.Equal(t, UserFixtures.Pointer("bob").UserName, result.UserName) } - if cached, err := FindSession("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"); err != nil { + if cached, err := FindSession(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1")); err != nil { t.Fatal(err) } else { - assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1", cached.ID) + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"), cached.ID) assert.Equal(t, UserFixtures.Pointer("bob").UserUID, cached.UserUID) assert.Equal(t, UserFixtures.Pointer("bob").UserName, cached.UserName) } }) t.Run("Visitor", func(t *testing.T) { - if result, err := FindSession("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"); err != nil { + if result, err := FindSession(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3")); err != nil { t.Fatal(err) } else { - assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3", result.ID) + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"), result.ID) assert.Equal(t, Visitor.UserUID, result.UserUID) assert.Equal(t, Visitor.UserName, result.UserName) } - if cached, err := FindSession("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"); err != nil { + if cached, err := FindSession(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3")); err != nil { t.Fatal(err) } else { - assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3", cached.ID) + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"), cached.ID) assert.Equal(t, Visitor.UserUID, cached.UserUID) assert.Equal(t, Visitor.UserName, cached.UserName) } @@ -84,26 +150,26 @@ func TestFindSession(t *testing.T) { func TestCacheSession(t *testing.T) { t.Run("bob", func(t *testing.T) { sessionCache.Flush() - r, b := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") + r, b := sessionCache.Get(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1")) assert.Empty(t, r) assert.False(t, b) bob := FindSessionByRefID("sessxkkcabce") CacheSession(bob, time.Hour) - r2, b2 := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") + r2, b2 := sessionCache.Get(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1")) assert.NotEmpty(t, r2) assert.True(t, b2) sessionCache.Flush() - r3, b3 := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") + r3, b3 := sessionCache.Get(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1")) assert.Empty(t, r3) assert.False(t, b3) }) t.Run("duration 0", func(t *testing.T) { - r, b := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0") + r, b := sessionCache.Get(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")) assert.Empty(t, r) assert.False(t, b) alice := FindSessionByRefID("sessxkkcabcd") CacheSession(alice, 0) - r2, b2 := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0") + r2, b2 := sessionCache.Get(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")) assert.NotEmpty(t, r2) assert.True(t, b2) sessionCache.Flush() @@ -122,28 +188,30 @@ func TestCacheSession(t *testing.T) { } func TestDeleteSession(t *testing.T) { - m := &Session{ID: "77be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1", DownloadToken: "download123", PreviewToken: "preview123"} + id := rnd.SessionID("77be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") + m := &Session{ID: id, DownloadToken: "download123", PreviewToken: "preview123"} CacheSession(m, time.Hour) - r, _ := sessionCache.Get("77be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") + r, _ := sessionCache.Get(id) assert.NotEmpty(t, r) DeleteSession(m) - r2, _ := sessionCache.Get("77be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") + r2, _ := sessionCache.Get(id) assert.Empty(t, r2) } func TestDeleteFromSessionCache(t *testing.T) { + id := rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") sessionCache.Flush() bob := FindSessionByRefID("sessxkkcabce") CacheSession(bob, time.Hour) - r, b := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") + r, b := sessionCache.Get(id) assert.NotEmpty(t, r) assert.True(t, b) DeleteFromSessionCache("") - r2, b2 := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") + r2, b2 := sessionCache.Get(id) assert.NotEmpty(t, r2) assert.True(t, b2) - DeleteFromSessionCache("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") - r3, b3 := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") + DeleteFromSessionCache(id) + r3, b3 := sessionCache.Get(id) assert.Empty(t, r3) assert.False(t, b3) } diff --git a/internal/entity/auth_session_fixtures.go b/internal/entity/auth_session_fixtures.go index 9b0332bc4..e4dad9383 100644 --- a/internal/entity/auth_session_fixtures.go +++ b/internal/entity/auth_session_fixtures.go @@ -3,6 +3,7 @@ package entity import ( "github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/rnd" ) type SessionMap map[string]Session @@ -25,7 +26,8 @@ func (m SessionMap) Pointer(name string) *Session { var SessionFixtures = SessionMap{ "alice": { - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", + authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", + ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"), RefID: "sessxkkcabcd", SessTimeout: UnixDay * 3, SessExpires: UnixTime() + UnixWeek, @@ -34,7 +36,8 @@ var SessionFixtures = SessionMap{ UserName: UserFixtures.Pointer("alice").UserName, }, "alice_token": { - ID: "bb8658e779403ae524a188712470060f050054324a8b104e", + authToken: "bb8658e779403ae524a188712470060f050054324a8b104e", + ID: rnd.SessionID("bb8658e779403ae524a188712470060f050054324a8b104e"), RefID: "sess34q3hael", SessTimeout: -1, SessExpires: UnixTime() + UnixDay, @@ -47,7 +50,8 @@ var SessionFixtures = SessionMap{ UserName: UserFixtures.Pointer("alice").UserName, }, "alice_token_scope": { - ID: "778f0f7d80579a072836c65b786145d6e0127505194cc51e", + authToken: "778f0f7d80579a072836c65b786145d6e0127505194cc51e", + ID: rnd.SessionID("778f0f7d80579a072836c65b786145d6e0127505194cc51e"), RefID: "sessjr0ge18d", SessTimeout: 0, SessExpires: UnixTime() + UnixDay, @@ -61,7 +65,8 @@ var SessionFixtures = SessionMap{ DownloadToken: "64ydcbom", }, "bob": { - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1", + authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1", + ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"), RefID: "sessxkkcabce", SessTimeout: UnixDay * 3, SessExpires: UnixTime() + UnixWeek, @@ -70,7 +75,8 @@ var SessionFixtures = SessionMap{ UserName: UserFixtures.Pointer("bob").UserName, }, "unauthorized": { - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2", + authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2", + ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2"), RefID: "sessxkkcabcf", SessTimeout: UnixDay * 3, SessExpires: UnixTime() + UnixWeek, @@ -79,7 +85,8 @@ var SessionFixtures = SessionMap{ UserName: UserFixtures.Pointer("unauthorized").UserName, }, "visitor": { - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3", + authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3", + ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"), RefID: "sessxkkcabcg", SessTimeout: UnixDay * 3, SessExpires: UnixTime() + UnixWeek, @@ -93,7 +100,8 @@ var SessionFixtures = SessionMap{ }, }, "visitor_token_metrics": { - ID: "4ebe1048a7384e1e6af2930b5b6f29795ffab691df47a488", + authToken: "4ebe1048a7384e1e6af2930b5b6f29795ffab691df47a488", + ID: rnd.SessionID("4ebe1048a7384e1e6af2930b5b6f29795ffab691df47a488"), RefID: "sessaae5cxun", SessTimeout: 0, SessExpires: UnixTime() + UnixWeek, @@ -105,7 +113,8 @@ var SessionFixtures = SessionMap{ UserName: Visitor.UserName, }, "friend": { - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4", + authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4", + ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4"), RefID: "sessxkkcabch", SessTimeout: UnixDay * 3, SessExpires: UnixTime() + UnixWeek, @@ -114,7 +123,8 @@ var SessionFixtures = SessionMap{ UserName: UserFixtures.Pointer("friend").UserName, }, "token_metrics": { - ID: "9d8b8801ffa23eb52e08ca7766283799ddfd8dd368208a9b", + authToken: "9d8b8801ffa23eb52e08ca7766283799ddfd8dd368208a9b", + ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368208a9b"), RefID: "sessgh6gjuo1", SessTimeout: 0, SessExpires: UnixTime() + UnixWeek, @@ -128,7 +138,8 @@ var SessionFixtures = SessionMap{ DownloadToken: "vgln2ffb", }, "token_settings": { - ID: "3f9684f7d3dd3d5b84edd43289c7fb5ca32ee73bd0233237", + authToken: "3f9684f7d3dd3d5b84edd43289c7fb5ca32ee73bd0233237", + ID: rnd.SessionID("3f9684f7d3dd3d5b84edd43289c7fb5ca32ee73bd0233237"), RefID: "sessyugn54so", SessTimeout: 0, SessExpires: UnixTime() + UnixWeek, diff --git a/internal/entity/auth_session_fixtures_test.go b/internal/entity/auth_session_fixtures_test.go index 257024cce..5313d1da7 100644 --- a/internal/entity/auth_session_fixtures_test.go +++ b/internal/entity/auth_session_fixtures_test.go @@ -10,7 +10,8 @@ func TestSessionMap_Get(t *testing.T) { t.Run("Alice", func(t *testing.T) { r := SessionFixtures.Get("alice") assert.Equal(t, "alice", r.UserName) - assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", r.ID) + assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", r.AuthToken()) + assert.Equal(t, "a3859489780243a78b331bd44f58255b552dee104041a45c0e79b610f63af2e5", r.ID) assert.IsType(t, Session{}, r) }) @@ -25,7 +26,8 @@ func TestSessionMap_Get(t *testing.T) { func TestSessionMap_Pointer(t *testing.T) { t.Run("Alice", func(t *testing.T) { r := SessionFixtures.Pointer("alice") - assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", r.ID) + assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", r.AuthToken()) + assert.Equal(t, "a3859489780243a78b331bd44f58255b552dee104041a45c0e79b610f63af2e5", r.ID) assert.Equal(t, "alice", r.UserName) assert.IsType(t, &Session{}, r) }) diff --git a/internal/entity/auth_session_login.go b/internal/entity/auth_session_login.go index f55ff20b9..394c72661 100644 --- a/internal/entity/auth_session_login.go +++ b/internal/entity/auth_session_login.go @@ -98,7 +98,7 @@ func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) { // Login credentials provided? if f.HasCredentials() { if m.IsRegistered() { - m.RegenerateID() + m.Regenerate() } user, provider, err = Auth(f, m, c) diff --git a/internal/entity/auth_session_test.go b/internal/entity/auth_session_test.go index c59939295..6616d5416 100644 --- a/internal/entity/auth_session_test.go +++ b/internal/entity/auth_session_test.go @@ -4,10 +4,11 @@ import ( "testing" "time" - "github.com/photoprism/photoprism/pkg/authn" + "github.com/photoprism/photoprism/internal/server/header" "github.com/stretchr/testify/assert" + "github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/rnd" ) @@ -15,6 +16,7 @@ func TestNewSession(t *testing.T) { t.Run("NoSessionData", func(t *testing.T) { m := NewSession(UnixDay, UnixHour*6) + assert.True(t, rnd.IsAuthToken(m.AuthToken())) assert.True(t, rnd.IsSessionID(m.ID)) assert.False(t, m.CreatedAt.IsZero()) assert.False(t, m.UpdatedAt.IsZero()) @@ -27,6 +29,7 @@ func TestNewSession(t *testing.T) { m := NewSession(UnixDay, UnixHour*6) m.SetData(NewSessionData()) + assert.True(t, rnd.IsAuthToken(m.AuthToken())) assert.True(t, rnd.IsSessionID(m.ID)) assert.False(t, m.CreatedAt.IsZero()) assert.False(t, m.UpdatedAt.IsZero()) @@ -41,6 +44,7 @@ func TestNewSession(t *testing.T) { m := NewSession(UnixDay, UnixHour*6) m.SetData(data) + assert.True(t, rnd.IsAuthToken(m.AuthToken())) assert.True(t, rnd.IsSessionID(m.ID)) assert.False(t, m.CreatedAt.IsZero()) assert.False(t, m.UpdatedAt.IsZero()) @@ -116,42 +120,80 @@ func TestFindSessionByRefID(t *testing.T) { }) } -func TestSession_RegenerateID(t *testing.T) { - t.Run("Success", func(t *testing.T) { +func TestSession_Regenerate(t *testing.T) { + t.Run("NewSession", func(t *testing.T) { m := NewSession(UnixDay, UnixHour) initialID := m.ID - m.RegenerateID() + m.Regenerate() finalID := m.ID assert.NotEqual(t, initialID, finalID) }) - t.Run("Empty ID", func(t *testing.T) { + t.Run("Empty", func(t *testing.T) { m := Session{ID: ""} initialID := m.ID - m.RegenerateID() + m.Regenerate() finalID := m.ID assert.NotEqual(t, initialID, finalID) }) - t.Run("Can't delete", func(t *testing.T) { + t.Run("Existing", func(t *testing.T) { m := Session{ID: "1234567"} initialID := m.ID - m.RegenerateID() + m.Regenerate() finalID := m.ID assert.NotEqual(t, initialID, finalID) }) } +func TestSession_AuthToken(t *testing.T) { + t.Run("New", func(t *testing.T) { + alice := SessionFixtures.Get("alice") + sess := &Session{} + assert.Equal(t, "", sess.ID) + 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()) + sess.Regenerate() + assert.True(t, rnd.IsSessionID(sess.ID)) + assert.True(t, rnd.IsAuthToken(sess.AuthToken())) + assert.Equal(t, header.BearerAuth, 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()) + }) + 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()) + }) + 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()) + sess.SetAuthToken(alice.AuthToken()) + assert.Equal(t, "a3859489780243a78b331bd44f58255b552dee104041a45c0e79b610f63af2e5", sess.ID) + assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", sess.AuthToken()) + assert.Equal(t, header.BearerAuth, sess.AuthTokenType()) + }) +} + func TestSession_Create(t *testing.T) { t.Run("Success", func(t *testing.T) { m := FindSessionByRefID("sessxkkcxxxx") assert.Empty(t, m) s := &Session{ - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7xxx", UserName: "charles", SessExpires: UnixDay * 3, SessTimeout: UnixTime() + UnixWeek, RefID: "sessxkkcxxxx", } + s.SetAuthToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7xxx") + err := s.Create() if err != nil { @@ -162,35 +204,44 @@ func TestSession_Create(t *testing.T) { assert.Equal(t, "charles", m2.UserName) }) t.Run("Invalid RefID", func(t *testing.T) { - m, _ := FindSession("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7111") + authToken := "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7111" + id := rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7111") + + m, _ := FindSession(id) + assert.Empty(t, m) + s := &Session{ - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7111", UserName: "charles", SessExpires: UnixDay * 3, SessTimeout: UnixTime() + UnixWeek, RefID: "123", } + s.SetAuthToken(authToken) + err := s.Create() if err != nil { t.Fatal(err) } - m2, _ := FindSession("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7111") + m2, _ := FindSession(id) assert.NotEqual(t, "123", m2.RefID) }) t.Run("ID already exists", func(t *testing.T) { + authToken := "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0" + s := &Session{ - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", UserName: "charles", SessExpires: UnixDay * 3, SessTimeout: UnixTime() + UnixWeek, RefID: "sessxkkcxxxx", } + s.SetAuthToken(authToken) + err := s.Create() assert.Error(t, err) }) @@ -201,13 +252,14 @@ func TestSession_Save(t *testing.T) { m := FindSessionByRefID("sessxkkcxxxy") assert.Empty(t, m) s := &Session{ - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7xxy", UserName: "chris", SessExpires: UnixDay * 3, SessTimeout: UnixTime() + UnixWeek, RefID: "sessxkkcxxxy", } + s.SetAuthToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7xxy") + err := s.Save() if err != nil { diff --git a/internal/entity/auth_user_test.go b/internal/entity/auth_user_test.go index 6e26bd9be..ef2f0d5de 100644 --- a/internal/entity/auth_user_test.go +++ b/internal/entity/auth_user_test.go @@ -1766,7 +1766,7 @@ func TestUser_DeleteSessions(t *testing.T) { t.Run("alice", func(t *testing.T) { m := FindLocalUser("alice") - assert.Equal(t, 0, m.DeleteSessions([]string{"69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"})) + assert.Equal(t, 0, m.DeleteSessions([]string{rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")})) assert.Equal(t, 1, m.DeleteSessions([]string{})) }) } @@ -1792,7 +1792,6 @@ func TestUser_RegenerateTokens(t *testing.T) { assert.NotEqual(t, preview, Admin.PreviewToken) assert.NotEqual(t, download, Admin.DownloadToken) - }) } diff --git a/internal/query/sessions.go b/internal/query/sessions.go index 5bdb8e837..758f32c47 100644 --- a/internal/query/sessions.go +++ b/internal/query/sessions.go @@ -14,8 +14,10 @@ func Session(id string) (result entity.Session, err error) { return result, fmt.Errorf("invalid session id") } else if rnd.IsRefID(id) { err = Db().Where("ref_id = ?", id).First(&result).Error - } else { + } else if rnd.IsSessionID(id) { err = Db().Where("id LIKE ?", id).First(&result).Error + } else { + err = Db().Where("id LIKE ?", rnd.SessionID(id)).First(&result).Error } return result, err @@ -32,6 +34,8 @@ func Sessions(limit, offset int, sortOrder, search string) (result entity.Sessio stmt = stmt.Where("sess_expires > 0 AND sess_expires < ?", entity.UnixTime()) } else if rnd.IsSessionID(search) { stmt = stmt.Where("id = ?", search) + } else if rnd.IsAuthToken(search) { + stmt = stmt.Where("id = ?", rnd.SessionID(search)) } else if rnd.IsUID(search, entity.UserUID) { stmt = stmt.Where("user_uid = ?", search) } else if search != "" { diff --git a/internal/query/sessions_test.go b/internal/query/sessions_test.go index 0561fd020..3d7ffca25 100644 --- a/internal/query/sessions_test.go +++ b/internal/query/sessions_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/pkg/rnd" ) func TestSession(t *testing.T) { @@ -22,7 +24,7 @@ func TestSession(t *testing.T) { } else { t.Logf("session: %#v", result) assert.NotNil(t, result) - assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", result.ID) + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"), result.ID) assert.Equal(t, "uqxetse3cy5eo9z2", result.UserUID) assert.Equal(t, "alice", result.UserName) } @@ -33,7 +35,7 @@ func TestSession(t *testing.T) { } else { t.Logf("session: %#v", result) assert.NotNil(t, result) - assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1", result.ID) + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"), result.ID) assert.Equal(t, "uqxc08w3d0ej2283", result.UserUID) assert.Equal(t, "bob", result.UserName) } @@ -72,7 +74,7 @@ func TestSessions(t *testing.T) { t.Logf("sessions: %#v", results) assert.LessOrEqual(t, 1, len(results)) if len(results) > 0 { - assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", results[0].ID) + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"), results[0].ID) assert.Equal(t, "uqxetse3cy5eo9z2", results[0].UserUID) assert.Equal(t, "alice", results[0].UserName) } diff --git a/internal/search/sessions_test.go b/internal/search/sessions_test.go index 3b8ed39a4..5104a5a2f 100644 --- a/internal/search/sessions_test.go +++ b/internal/search/sessions_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/rnd" ) func TestSessions(t *testing.T) { @@ -40,7 +41,7 @@ func TestSessions(t *testing.T) { t.Logf("sessions: %#v", results) assert.LessOrEqual(t, 1, len(results)) if len(results) > 0 { - assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", results[0].ID) + assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"), results[0].ID) assert.Equal(t, "uqxetse3cy5eo9z2", results[0].UserUID) assert.Equal(t, "alice", results[0].UserName) } diff --git a/internal/server/header/auth.go b/internal/server/header/auth.go index 1bdee2847..73dac2548 100644 --- a/internal/server/header/auth.go +++ b/internal/server/header/auth.go @@ -3,4 +3,6 @@ package header const ( SessionID = "X-Session-ID" Authorization = "Authorization" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization + BasicAuth = "Basic" + BearerAuth = "Bearer" ) diff --git a/internal/session/id.go b/internal/session/id.go deleted file mode 100644 index 5870845f8..000000000 --- a/internal/session/id.go +++ /dev/null @@ -1,10 +0,0 @@ -package session - -import ( - "github.com/photoprism/photoprism/pkg/rnd" -) - -// ID returns a random 48-character session id string. -func ID() string { - return rnd.SessionID() -} diff --git a/internal/session/id_test.go b/internal/session/id_test.go deleted file mode 100644 index 2b9c8e55e..000000000 --- a/internal/session/id_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package session - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewID(t *testing.T) { - for n := 0; n < 5; n++ { - id := ID() - t.Logf("id: %s", id) - assert.Equal(t, 48, len(id)) - } -} diff --git a/internal/session/session_get_test.go b/internal/session/session_get_test.go index 850916ed1..da337921d 100644 --- a/internal/session/session_get_test.go +++ b/internal/session/session_get_test.go @@ -52,7 +52,7 @@ func TestSession_Exists(t *testing.T) { t.Logf("ID: %s", sess.ID) - assert.Equal(t, 48, len(sess.ID)) + assert.Equal(t, 64, len(sess.ID)) assert.True(t, s.Exists(sess.ID)) err = s.Delete(sess.ID) diff --git a/internal/session/session_public.go b/internal/session/session_public.go index 3d9ce6023..30ad65968 100644 --- a/internal/session/session_public.go +++ b/internal/session/session_public.go @@ -2,24 +2,32 @@ package session import ( "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/rnd" ) -var Public *entity.Session -var PublicID = "234200000000000000000000000000000000000000000000" +// PublicAuthToken is a static authentication token used in public mode. +var PublicAuthToken = "234200000000000000000000000000000000000000000000" + +// PublicID is the SHA256 hash of the PublicAuthToken: +// a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f +var PublicID = rnd.SessionID(PublicAuthToken) + +// public references the existing public mode session entity. +var public *entity.Session // Public returns a client session for use in public mode. func (s *Session) Public() *entity.Session { - if Public == nil { + if public == nil { // Do nothing. - } else if !Public.Expired() { - return Public + } else if !public.Expired() { + return public } - Public = entity.NewSession(0, 0) - Public.ID = PublicID - Public.AuthMethod = "public" - Public.SetUser(&entity.Admin) - Public.CacheDuration(-1) + public = entity.NewSession(0, 0) + public.SetAuthToken(PublicAuthToken) + public.AuthMethod = "public" + public.SetUser(&entity.Admin) + public.CacheDuration(-1) - return Public + return public } diff --git a/internal/session/session_save_test.go b/internal/session/session_save_test.go index 58921b7fe..858a2eec8 100644 --- a/internal/session/session_save_test.go +++ b/internal/session/session_save_test.go @@ -14,9 +14,11 @@ import ( func TestSession_Save(t *testing.T) { s := New(config.TestConfig()) - id := ID() + authToken := rnd.AuthToken() + id := rnd.SessionID(authToken) - assert.Equal(t, 48, len(id)) + assert.Equal(t, 48, len(authToken)) + assert.Equal(t, 64, len(id)) assert.Falsef(t, s.Exists(id), "session %s should not exist", clean.LogQuote(id)) var err error @@ -32,8 +34,8 @@ func TestSession_Save(t *testing.T) { t.Fatal(err) } - assert.Equal(t, 48, len(m.ID)) - assert.Truef(t, s.Exists(m.ID), "session %s should exist", clean.LogQuote(id)) + assert.Equal(t, 64, len(m.ID)) + assert.Truef(t, s.Exists(m.ID), "session %s should exist", clean.LogQuote(m.ID)) newData := &entity.SessionData{ Shares: entity.UIDs{"a000000000000001"}, @@ -65,5 +67,6 @@ func TestSession_Create(t *testing.T) { assert.NotEmpty(t, sess) assert.NotEmpty(t, sess.ID) assert.NotEmpty(t, sess.RefID) + assert.True(t, rnd.IsAuthToken(sess.AuthToken())) assert.True(t, rnd.IsSessionID(sess.ID)) } diff --git a/pkg/rnd/session.go b/pkg/rnd/session.go index a88de2fb5..f62cc0cc7 100644 --- a/pkg/rnd/session.go +++ b/pkg/rnd/session.go @@ -6,8 +6,8 @@ import ( "log" ) -// SessionID returns a new session id. -func SessionID() string { +// AuthToken returns a new session id. +func AuthToken() string { b := make([]byte, 24) if _, err := rand.Read(b); err != nil { @@ -17,11 +17,25 @@ func SessionID() string { return fmt.Sprintf("%x", b) } -// IsSessionID checks if the string is a session id. -func IsSessionID(s string) bool { +// IsAuthToken checks if the string is a session id. +func IsAuthToken(s string) bool { if len(s) != 48 { return false } return IsHex(s) } + +// SessionID returns the hashed session id string. +func SessionID(s string) string { + return Sha256([]byte(s)) +} + +// IsSessionID checks if the string is a session id string. +func IsSessionID(s string) bool { + if len(s) != 64 { + return false + } + + return IsHex(s) +} diff --git a/pkg/rnd/session_test.go b/pkg/rnd/session_test.go index 22641e446..846c197bd 100644 --- a/pkg/rnd/session_test.go +++ b/pkg/rnd/session_test.go @@ -6,10 +6,37 @@ import ( "github.com/stretchr/testify/assert" ) +func TestAuthToken(t *testing.T) { + result := AuthToken() + assert.Equal(t, 48, len(result)) + assert.True(t, IsAuthToken(result)) + assert.True(t, IsHex(result)) +} + +func TestIsAuthToken(t *testing.T) { + assert.True(t, IsAuthToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2")) + assert.True(t, IsAuthToken(AuthToken())) + assert.True(t, IsAuthToken(AuthToken())) + assert.False(t, IsAuthToken(SessionID(AuthToken()))) + assert.False(t, IsAuthToken(SessionID(AuthToken()))) + assert.False(t, IsAuthToken("55785BAC-9H4B-4747-B090-EE123FFEE437")) + assert.False(t, IsAuthToken("4B1FEF2D1CF4A5BE38B263E0637EDEAD")) + assert.False(t, IsAuthToken("")) +} + +func TestSessionID(t *testing.T) { + result := SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2") + assert.Equal(t, 64, len(result)) + assert.Equal(t, "f22383a703805a031a9835c8c6b6dafb793a21e8f33d0b4887b4ec9bd7ac8cd5", result) +} + func TestIsSessionID(t *testing.T) { - assert.True(t, IsSessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2")) - assert.True(t, IsSessionID(SessionID())) - assert.True(t, IsSessionID(SessionID())) + assert.False(t, IsSessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2")) + assert.False(t, IsSessionID(AuthToken())) + assert.False(t, IsSessionID(AuthToken())) + assert.True(t, IsSessionID(SessionID(AuthToken()))) + assert.True(t, IsSessionID(SessionID(AuthToken()))) assert.False(t, IsSessionID("55785BAC-9H4B-4747-B090-EE123FFEE437")) assert.False(t, IsSessionID("4B1FEF2D1CF4A5BE38B263E0637EDEAD")) + assert.False(t, IsSessionID("")) } diff --git a/pkg/rnd/sha.go b/pkg/rnd/sha.go new file mode 100644 index 000000000..64bb8183f --- /dev/null +++ b/pkg/rnd/sha.go @@ -0,0 +1,22 @@ +package rnd + +import ( + "crypto/sha256" + "crypto/sha512" + "fmt" +) + +// Sha224 returns the SHA224 checksum of the byte slice as a hex string. +func Sha224(b []byte) string { + return fmt.Sprintf("%x", sha256.Sum224(b)) +} + +// Sha256 returns the SHA256 checksum of the byte slice as a hex string. +func Sha256(b []byte) string { + return fmt.Sprintf("%x", sha256.Sum256(b)) +} + +// Sha512 returns the SHA512 checksum of the byte slice as a hex string. +func Sha512(b []byte) string { + return fmt.Sprintf("%x", sha512.Sum512(b)) +} diff --git a/pkg/rnd/sha_test.go b/pkg/rnd/sha_test.go new file mode 100644 index 000000000..88e1a5b8c --- /dev/null +++ b/pkg/rnd/sha_test.go @@ -0,0 +1,67 @@ +package rnd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSha224(t *testing.T) { + t.Run("Nil", func(t *testing.T) { + s := Sha224(nil) + t.Logf("Sha224(nil): %s", s) + assert.NotEmpty(t, s) + assert.True(t, IsHex(s)) + assert.Equal(t, 56, len(s)) + + }) + t.Run("HelloWorld", func(t *testing.T) { + s := Sha224([]byte("hello world\n")) + t.Logf("Sha224(HelloWorld): %s", s) + assert.NotEmpty(t, s) + assert.True(t, IsHex(s)) + assert.Equal(t, "95041dd60ab08c0bf5636d50be85fe9790300f39eb84602858a9b430", s) + assert.Equal(t, 56, len(s)) + + }) +} + +func TestSha256(t *testing.T) { + t.Run("Nil", func(t *testing.T) { + s := Sha256(nil) + t.Logf("Sha256(nil): %s", s) + assert.NotEmpty(t, s) + assert.True(t, IsHex(s)) + assert.Equal(t, 64, len(s)) + + }) + t.Run("HelloWorld", func(t *testing.T) { + s := Sha256([]byte("hello world\n")) + t.Logf("Sha256(HelloWorld): %s", s) + assert.NotEmpty(t, s) + assert.True(t, IsHex(s)) + assert.Equal(t, "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447", s) + assert.Equal(t, 64, len(s)) + + }) +} + +func TestSha512(t *testing.T) { + t.Run("Nil", func(t *testing.T) { + s := Sha512(nil) + t.Logf("Sha512(nil): %s", s) + assert.NotEmpty(t, s) + assert.True(t, IsHex(s)) + assert.Equal(t, 128, len(s)) + + }) + t.Run("HelloWorld", func(t *testing.T) { + s := Sha512([]byte("hello world\n")) + t.Logf("Sha512(HelloWorld): %s", s) + assert.NotEmpty(t, s) + assert.True(t, IsHex(s)) + assert.Equal(t, "db3974a97f2407b7cae1ae637c0030687a11913274d578492558e39c16c017de84eacdc8c62fe34ee4e12b4b1428817f09b6a2760c3f8a664ceae94d2434a593", s) + assert.Equal(t, 128, len(s)) + + }) +} diff --git a/pkg/rnd/type.go b/pkg/rnd/type.go index 9b370637b..9dd48af75 100644 --- a/pkg/rnd/type.go +++ b/pkg/rnd/type.go @@ -39,7 +39,7 @@ func IdType(id string) (Type, byte) { return TypeSHA1, PrefixNone case IsRefID(id): return TypeRefID, PrefixNone - case IsSessionID(id): + case IsAuthToken(id): return TypeSessionID, PrefixNone case ValidateCrcToken(id): return TypeCrcToken, PrefixNone