Auth: Use hashed auth tokens for enhanced security #3943 #808 #782

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2024-01-06 17:35:19 +01:00
parent 1d28cbcd92
commit 0d2f8be522
46 changed files with 1106 additions and 378 deletions

View File

@@ -47,7 +47,7 @@ const Api = Axios.create({
baseURL: c.apiUri, baseURL: c.apiUri,
headers: { headers: {
common: { common: {
"X-Session-ID": window.localStorage.getItem("session_id"), "X-Session-ID": window.localStorage.getItem("authToken"),
"X-Client-Uri": c.jsUri, "X-Client-Uri": c.jsUri,
"X-Client-Version": c.version, "X-Client-Version": c.version,
}, },

View File

@@ -28,8 +28,9 @@ import Event from "pubsub-js";
import User from "model/user"; import User from "model/user";
import Socket from "websocket.js"; import Socket from "websocket.js";
const SessionHeader = "X-Session-ID"; const RequestHeader = "X-Session-ID";
const PublicID = "234200000000000000000000000000000000000000000000"; const PublicSessionID = "a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f";
const PublicAuthToken = "234200000000000000000000000000000000000000000000";
const LoginPage = "login"; const LoginPage = "login";
export default class Session { export default class Session {
@@ -39,7 +40,7 @@ export default class Session {
* @param {object} shared * @param {object} shared
*/ */
constructor(storage, config, shared) { constructor(storage, config, shared) {
this.storage_key = "session_storage"; this.storage_key = "sessionStorage";
this.auth = false; this.auth = false;
this.config = config; this.config = config;
this.user = new User(false); this.user = new User(false);
@@ -52,9 +53,12 @@ export default class Session {
this.storage = storage; this.storage = storage;
} }
// Restore from session storage. // Restore authentication from session storage.
if (this.applyId(this.storage.getItem("session_id"))) { if (
const dataJson = this.storage.getItem("data"); this.applyAuthToken(this.storage.getItem("authToken")) &&
this.applyId(this.storage.getItem("sessionId"))
) {
const dataJson = this.storage.getItem("sessionData");
if (dataJson !== "undefined") { if (dataJson !== "undefined") {
this.data = JSON.parse(dataJson); this.data = JSON.parse(dataJson);
} }
@@ -113,42 +117,91 @@ export default class Session {
this.storage = window.localStorage; this.storage = window.localStorage;
} }
applyId(id) { setConfig(values) {
if (!id) { 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(); this.reset();
return false; return false;
} }
this.session_id = id; this.authToken = authToken;
Api.defaults.headers.common[SessionHeader] = id; Api.defaults.headers.common[RequestHeader] = authToken;
return true; return true;
} }
setId(id) { setId(id) {
this.storage.setItem("session_id", id); this.storage.setItem("sessionId", id);
return this.applyId(id); this.id = id;
}
setConfig(values) {
this.config.setValues(values);
} }
getId() { getId() {
return this.session_id; return this.id;
} }
hasId() { hasId() {
return !!this.session_id; return !!this.id;
} }
deleteId() { applyId(id) {
this.session_id = null; if (!id) {
this.provider = ""; return false;
this.storage.removeItem("session_id"); }
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) { setResp(resp) {
@@ -159,15 +212,25 @@ export default class Session {
if (resp.data.id) { if (resp.data.id) {
this.setId(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) { if (resp.data.config) {
this.setConfig(resp.data.config); this.setConfig(resp.data.config);
} }
if (resp.data.user) { if (resp.data.user) {
this.setUser(resp.data.user); this.setUser(resp.data.user);
} }
if (resp.data.data) { if (resp.data.data) {
this.setData(resp.data.data); this.setData(resp.data.data);
} }
@@ -179,7 +242,7 @@ export default class Session {
} }
this.data = data; this.data = data;
this.storage.setItem("data", JSON.stringify(data)); this.storage.setItem("sessionData", JSON.stringify(data));
if (data.user) { if (data.user) {
this.setUser(data.user); this.setUser(data.user);
@@ -264,7 +327,7 @@ export default class Session {
deleteData() { deleteData() {
this.data = null; this.data = null;
this.storage.removeItem("data"); this.storage.removeItem("sessionData");
} }
deleteUser() { deleteUser() {
@@ -280,7 +343,7 @@ export default class Session {
} }
reset() { reset() {
this.deleteId(); this.deleteAuthentication();
this.deleteData(); this.deleteData();
this.deleteUser(); this.deleteUser();
this.deleteClipboard(); this.deleteClipboard();
@@ -289,7 +352,7 @@ export default class Session {
sendClientInfo() { sendClientInfo() {
const hasConfig = !!window.__CONFIG__; const hasConfig = !!window.__CONFIG__;
const clientInfo = { const clientInfo = {
session: this.getId(), session: this.getAuthToken(),
cssUri: hasConfig ? window.__CONFIG__.cssUri : "", cssUri: hasConfig ? window.__CONFIG__.cssUri : "",
jsUri: hasConfig ? window.__CONFIG__.jsUri : "", jsUri: hasConfig ? window.__CONFIG__.jsUri : "",
version: hasConfig ? window.__CONFIG__.version : "", version: hasConfig ? window.__CONFIG__.version : "",
@@ -327,16 +390,17 @@ export default class Session {
} }
refresh() { refresh() {
// Refresh session information. // Check if the authentication is still valid and update the client session data.
if (this.config.isPublic()) { if (this.config.isPublic()) {
// No authentication in public mode. // Use a static auth token in public mode, as no additional authentication is required.
this.setId(PublicID); this.setAuthToken(PublicAuthToken);
this.setId(PublicSessionID);
return Api.get("session/" + this.getId()).then((resp) => { return Api.get("session/" + this.getId()).then((resp) => {
this.setResp(resp); this.setResp(resp);
return Promise.resolve(); return Promise.resolve();
}); });
} else if (this.hasId()) { } else if (this.isAuthenticated()) {
// Verify authentication. // Check the auth token by fetching the client session data from the API.
return Api.get("session/" + this.getId()) return Api.get("session/" + this.getId())
.then((resp) => { .then((resp) => {
this.setResp(resp); this.setResp(resp);
@@ -350,7 +414,7 @@ export default class Session {
return Promise.reject(); return Promise.reject();
}); });
} else { } else {
// No authentication yet. // Skip updating session data if client is not authenticated.
return Promise.resolve(); return Promise.resolve();
} }
} }
@@ -367,15 +431,19 @@ export default class Session {
} }
onLogout(noRedirect) { onLogout(noRedirect) {
// Delete all authentication and session data.
this.reset(); this.reset();
// Perform redirect?
if (noRedirect !== true && !this.isLogin()) { if (noRedirect !== true && !this.isLogin()) {
window.location = this.config.baseUri + "/"; window.location = this.config.baseUri + "/";
} }
return Promise.resolve(); return Promise.resolve();
} }
logout(noRedirect) { logout(noRedirect) {
if (this.hasId()) { if (this.isAuthenticated()) {
return Api.delete("session/" + this.getId()) return Api.delete("session/" + this.getId())
.then(() => { .then(() => {
return this.onLogout(noRedirect); return this.onLogout(noRedirect);

View File

@@ -14,19 +14,19 @@ describe("common/session", () => {
it("should construct session", () => { it("should construct session", () => {
const storage = new StorageShim(); const storage = new StorageShim();
const session = new Session(storage, config); const session = new Session(storage, config);
assert.equal(session.session_id, null); assert.equal(session.authToken, null);
}); });
it("should set, get and delete token", () => { it("should set, get and delete token", () => {
const storage = new StorageShim(); const storage = new StorageShim();
const session = new Session(storage, config); const session = new Session(storage, config);
assert.equal(session.hasToken("2lbh9x09"), false); assert.equal(session.hasToken("2lbh9x09"), false);
session.setId("999900000000000000000000000000000000000000000000"); session.setAuthToken("999900000000000000000000000000000000000000000000");
assert.equal(session.session_id, "999900000000000000000000000000000000000000000000"); assert.equal(session.authToken, "999900000000000000000000000000000000000000000000");
const result = session.getId(); const result = session.getAuthToken();
assert.equal(result, "999900000000000000000000000000000000000000000000"); assert.equal(result, "999900000000000000000000000000000000000000000000");
session.reset(); session.reset();
assert.equal(session.session_id, null); assert.equal(session.authToken, null);
}); });
it("should set, get and delete user", () => { it("should set, get and delete user", () => {
@@ -48,9 +48,23 @@ describe("common/session", () => {
user, user,
}; };
assert.equal(session.hasId(), false);
assert.equal(session.hasAuthToken(), false);
assert.equal(session.isAuthenticated(), false);
assert.equal(session.hasProvider(), false);
session.setData(); session.setData();
assert.equal(session.user.DisplayName, ""); assert.equal(session.user.DisplayName, "");
session.setData(data); 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.DisplayName, "Max Example");
assert.equal(session.user.SuperAdmin, true); assert.equal(session.user.SuperAdmin, true);
assert.equal(session.user.Role, "admin"); assert.equal(session.user.Role, "admin");
@@ -79,6 +93,11 @@ describe("common/session", () => {
it("should get user email", () => { it("should get user email", () => {
const storage = new StorageShim(); const storage = new StorageShim();
const session = new Session(storage, config); const session = new Session(storage, config);
session.setId("a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f");
session.setAuthToken("234200000000000000000000000000000000000000000000");
session.setProvider("public");
const values = { const values = {
user: { user: {
ID: 5, ID: 5,
@@ -88,6 +107,7 @@ describe("common/session", () => {
Role: "admin", Role: "admin",
}, },
}; };
session.setData(values); session.setData(values);
const result = session.getEmail(); const result = session.getEmail();
assert.equal(result, "test@test.com"); assert.equal(result, "test@test.com");
@@ -121,6 +141,10 @@ describe("common/session", () => {
const result = session.getDisplayName(); const result = session.getDisplayName();
assert.equal(result, "Max Last"); assert.equal(result, "Max Last");
const values2 = { const values2 = {
id: "a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f",
access_token: "234200000000000000000000000000000000000000000000",
provider: "public",
data: {},
user: { user: {
ID: 5, ID: 5,
Name: "bar", Name: "bar",
@@ -221,18 +245,18 @@ describe("common/session", () => {
it("should use session storage", () => { it("should use session storage", () => {
const storage = new StorageShim(); const storage = new StorageShim();
const session = new Session(storage, config); const session = new Session(storage, config);
assert.equal(storage.getItem("session_storage"), null); assert.equal(storage.getItem("sessionStorage"), null);
session.useSessionStorage(); session.useSessionStorage();
assert.equal(storage.getItem("session_storage"), "true"); assert.equal(storage.getItem("sessionStorage"), "true");
session.deleteData(); session.deleteData();
}); });
it("should use local storage", () => { it("should use local storage", () => {
const storage = new StorageShim(); const storage = new StorageShim();
const session = new Session(storage, config); const session = new Session(storage, config);
assert.equal(storage.getItem("session_storage"), null); assert.equal(storage.getItem("sessionStorage"), null);
session.useLocalStorage(); session.useLocalStorage();
assert.equal(storage.getItem("session_storage"), "false"); assert.equal(storage.getItem("sessionStorage"), "false");
session.deleteData(); session.deleteData();
}); });

View File

@@ -133,36 +133,46 @@ Mock.onDelete("api/v1/photos/pqbemz8276mhtobh/label/12345").reply(
Mock.onPost("api/v1/session").reply( Mock.onPost("api/v1/session").reply(
200, 200,
{ {
id: "999900000000000000000000000000000000000000000000", id: "5aa770f2a1ef431628d9f17bdf82a0d16865e99d4a1ddd9356e1aabfe6464683",
access_token: "999900000000000000000000000000000000000000000000",
provider: "test",
data: { token: "123token" }, data: { token: "123token" },
user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" }, user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" },
}, },
mockHeaders mockHeaders
); );
Mock.onGet("api/v1/session/234200000000000000000000000000000000000000000000").reply( Mock.onGet("api/v1/session/a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f").reply(
200, 200,
{ {
id: "234200000000000000000000000000000000000000000000", id: "a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f",
access_token: "234200000000000000000000000000000000000000000000",
provider: "public",
data: { token: "123token" }, data: { token: "123token" },
user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" }, user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" },
}, },
mockHeaders mockHeaders
); );
Mock.onGet("api/v1/session/999900000000000000000000000000000000000000000000").reply( Mock.onGet("api/v1/session/5aa770f2a1ef431628d9f17bdf82a0d16865e99d4a1ddd9356e1aabfe6464683").reply(
200, 200,
{ {
id: "999900000000000000000000000000000000000000000000", id: "5aa770f2a1ef431628d9f17bdf82a0d16865e99d4a1ddd9356e1aabfe6464683",
access_token: "999900000000000000000000000000000000000000000000",
provider: "test",
data: { token: "123token" }, data: { token: "123token" },
user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" }, user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" },
}, },
mockHeaders 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.onGet("api/v1/settings").reply(200, { download: true, language: "de" }, mockHeaders);
Mock.onPost("api/v1/settings").reply(200, { download: true, language: "en" }, mockHeaders); Mock.onPost("api/v1/settings").reply(200, { download: true, language: "en" }, mockHeaders);

View File

@@ -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. Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.

View File

@@ -19,7 +19,7 @@ func UpdateClientConfig() {
// GET /api/v1/config // GET /api/v1/config
func GetClientConfig(router *gin.RouterGroup) { func GetClientConfig(router *gin.RouterGroup) {
router.GET("/config", func(c *gin.Context) { router.GET("/config", func(c *gin.Context) {
s := Session(SessionID(c)) s := Session(AuthToken(c))
conf := get.Config() conf := get.Config()
if s == nil { if s == nil {

View File

@@ -4,6 +4,7 @@ import (
"crypto/sha1" "crypto/sha1"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -11,9 +12,9 @@ import (
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
) )
// SessionID returns the session ID from the request context, // AuthToken returns the client authentication token from the request context,
// or an empty string if there is none. // or an empty string if none is found.
func SessionID(c *gin.Context) string { func AuthToken(c *gin.Context) string {
// Default is an empty string if no context or ID is set. // Default is an empty string if no context or ID is set.
if c == nil { if c == nil {
return "" return ""
@@ -28,9 +29,9 @@ func SessionID(c *gin.Context) string {
return BearerToken(c) 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 { 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 return bearerToken
} }
@@ -40,7 +41,9 @@ func BearerToken(c *gin.Context) string {
// Authorization returns the authentication type and token from the authorization request header, // Authorization returns the authentication type and token from the authorization request header,
// or an empty string if there is none. // or an empty string if there is none.
func Authorization(c *gin.Context) (authType, authToken string) { 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. // Ignore.
} else if t := strings.Split(s, " "); len(t) != 2 { } else if t := strings.Split(s, " "); len(t) != 2 {
// Ignore. // Ignore.
@@ -51,6 +54,13 @@ func Authorization(c *gin.Context) (authType, authToken string) {
return "", "" 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. // 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 // 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) { func BasicAuth(c *gin.Context) (username, password, cacheKey string) {
authType, authToken := Authorization(c) authType, authToken := Authorization(c)
if authType != "Basic" || authToken == "" { if authType != header.BasicAuth || authToken == "" {
return "", "", "" return "", "", ""
} }

View File

@@ -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)
})
}

View File

@@ -12,7 +12,9 @@ import (
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get" "github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/server/header"
) )
type CloseableResponseRecorder struct { type CloseableResponseRecorder struct {
@@ -53,7 +55,7 @@ func NewApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config
return app, router, get.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 // See https://medium.com/@craigchilds94/testing-gin-json-responses-1f258ce3b0b1
func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecorder { func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(method, path, nil) req, _ := http.NewRequest(method, path, nil)
@@ -63,7 +65,7 @@ func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecor
return w 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 { func PerformRequestWithBody(r http.Handler, method, path, body string) *httptest.ResponseRecorder {
reader := strings.NewReader(body) reader := strings.NewReader(body)
req, _ := http.NewRequest(method, path, reader) req, _ := http.NewRequest(method, path, reader)
@@ -74,7 +76,7 @@ func PerformRequestWithBody(r http.Handler, method, path, body string) *httptest
return w 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 { func PerformRequestWithStream(r http.Handler, method, path string) *CloseableResponseRecorder {
req, _ := http.NewRequest(method, path, nil) req, _ := http.NewRequest(method, path, nil)
w := &CloseableResponseRecorder{httptest.NewRecorder(), make(chan bool, 1)} w := &CloseableResponseRecorder{httptest.NewRecorder(), make(chan bool, 1)}
@@ -83,3 +85,49 @@ func PerformRequestWithStream(r http.Handler, method, path string) *CloseableRes
return w 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
}

View File

@@ -43,9 +43,9 @@ var wsConnection = websocket.Upgrader{
}, },
} }
// clientInfo represents information provided by the WebSocket client. // wsClient represents information about the WebSocket client.
type clientInfo struct { type wsClient struct {
SessionID string `json:"session"` AuthToken string `json:"session"`
CssUri string `json:"css"` CssUri string `json:"css"`
JsUri string `json:"js"` JsUri string `json:"js"`
Version string `json:"version"` 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 }) ws.SetPongHandler(func(string) error { _ = ws.SetReadDeadline(time.Now().Add(wsTimeout)); return nil })
for { for {
_, m, err := ws.ReadMessage() _, m, readErr := ws.ReadMessage()
if err != nil { if readErr != nil {
break 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. // Do nothing.
} else { } else {
if s := Session(info.SessionID); s != nil { if s := Session(info.AuthToken); s != nil {
wsAuth.mutex.Lock() wsAuth.mutex.Lock()
wsAuth.sid[connId] = s.ID wsAuth.sid[connId] = s.ID
wsAuth.rid[connId] = s.RefID wsAuth.rid[connId] = s.RefID

View File

@@ -20,7 +20,7 @@ const (
// PublishPhotoEvent publishes updated photo data after changes have been made. // PublishPhotoEvent publishes updated photo data after changes have been made.
func PublishPhotoEvent(ev EntityEvent, uid string, c *gin.Context) { func PublishPhotoEvent(ev EntityEvent, uid string, c *gin.Context) {
if result, _, err := search.Photos(form.SearchPhotos{UID: uid, Merged: true}); err != nil { 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 { } else {
event.PublishEntities("photos", string(ev), result) 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) { func PublishAlbumEvent(ev EntityEvent, uid string, c *gin.Context) {
f := form.SearchAlbums{UID: uid} f := form.SearchAlbums{UID: uid}
if result, err := search.Albums(f); err != nil { 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 { } else {
event.PublishEntities("albums", string(ev), result) 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) { func PublishLabelEvent(ev EntityEvent, uid string, c *gin.Context) {
f := form.SearchLabels{UID: uid} f := form.SearchLabels{UID: uid}
if result, err := search.Labels(f); err != nil { 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 { } else {
event.PublishEntities("labels", string(ev), result) 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) { func PublishSubjectEvent(ev EntityEvent, uid string, c *gin.Context) {
f := form.SearchSubjects{UID: uid} f := form.SearchSubjects{UID: uid}
if result, err := search.Subjects(f); err != nil { 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 { } else {
event.PublishEntities("subjects", string(ev), result) event.PublishEntities("subjects", string(ev), result)
} }

View File

@@ -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
}

View File

@@ -1,25 +1,65 @@
package api package api
import ( import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/get" "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. // Session finds the client session for the specified
func Session(id string) *entity.Session { // auth token, or returns nil if not found.
// Skip authentication if app is running in public mode. func Session(authToken string) *entity.Session {
// Skip authentication when running in public mode.
if get.Config().Public() { if get.Config().Public() {
return get.Session().Public() return get.Session().Public()
} else if id == "" { } else if !rnd.IsAuthToken(authToken) {
return nil return nil
} }
// Find session or otherwise return nil. // Find the session based on the hashed auth
s, err := get.Session().Get(id) // token used as id, or return nil otherwise.
if s, err := get.Session().Get(rnd.SessionID(authToken)); err != nil {
if err != nil {
return nil return nil
} } else {
return s 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}
}
} }

View File

@@ -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) { 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. // Get the client IP and session ID from the request headers.
ip := ClientIP(c) ip := ClientIP(c)
sid := SessionID(c) authToken := AuthToken(c)
// Find active session to perform authorization check or deny if no session was found. // 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)) event.AuditWarn([]string{ip, "unauthenticated", "%s %s", "denied"}, grants.String(), string(resource))
return entity.SessionStatusUnauthorized() return entity.SessionStatusUnauthorized()
} else { } else {

View File

@@ -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))
})
}

View File

@@ -31,15 +31,12 @@ func CreateSession(router *gin.RouterGroup) {
// Skip authentication if app is running in public mode. // Skip authentication if app is running in public mode.
if conf.Public() { if conf.Public() {
sess := get.Session().Public() sess := get.Session().Public()
data := gin.H{
"status": "ok", // Response includes admin account data, session data, and client config values.
"id": sess.ID, response := SessionResponse(sess.AuthToken(), sess, conf.ClientPublic())
"provider": sess.AuthProvider,
"user": sess.User(), // Return JSON response.
"data": sess.Data(), c.JSON(http.StatusOK, response)
"config": conf.ClientPublic(),
}
c.JSON(http.StatusOK, data)
return return
} }
@@ -53,7 +50,7 @@ func CreateSession(router *gin.RouterGroup) {
var isNew bool var isNew bool
// Find existing session, if any. // Find existing session, if any.
if s := Session(SessionID(c)); s != nil { if s := Session(AuthToken(c)); s != nil {
// Update existing session. // Update existing session.
sess = s sess = s
} else { } else {
@@ -80,22 +77,12 @@ func CreateSession(router *gin.RouterGroup) {
} }
// Add session id to response headers. // Add session id to response headers.
AddSessionHeader(c, sess.ID) AddSessionHeader(c, sess.AuthToken())
// Get config values for the UI. // Response includes user data, session data, and client config values.
clientConfig := conf.ClientSession(sess) response := SessionResponse(sess.AuthToken(), sess, conf.ClientSession(sess))
// User information, session data, and client config values. // Return JSON response.
data := gin.H{ c.JSON(sess.HttpStatus(), response)
"status": "ok",
"id": sess.ID,
"provider": sess.AuthProvider,
"user": sess.User(),
"data": sess.Data(),
"config": clientConfig,
}
// Send JSON response.
c.JSON(sess.HttpStatus(), data)
}) })
} }

View File

@@ -9,6 +9,7 @@ import (
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get" "github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
) )
@@ -20,21 +21,22 @@ func DeleteSession(router *gin.RouterGroup) {
router.DELETE("/session/:id", func(c *gin.Context) { router.DELETE("/session/:id", func(c *gin.Context) {
id := clean.ID(c.Param("id")) id := clean.ID(c.Param("id"))
// Abort if ID is missing. // Abort if authentication token is missing or empty.
if id == "" { if id == "" {
AbortBadRequest(c) AbortBadRequest(c)
return return
} else if get.Config().Public() { } 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 return
} }
// Find session by reference ID. // Only admins may delete other sessions by reference id.
if !rnd.IsRefID(id) { if rnd.IsRefID(id) {
// Do nothing. if s := Session(AuthToken(c)); s == nil {
} else if s := Session(SessionID(c)); s == nil {
entity.SessionStatusUnauthorized().Abort(c) entity.SessionStatusUnauthorized().Abort(c)
return return
} else if s.Abort(c) {
return
} else if !acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) { } else if !acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
s.Abort(c) s.Abort(c)
return return
@@ -44,14 +46,29 @@ func DeleteSession(router *gin.RouterGroup) {
} else { } else {
id = ref.ID id = ref.ID
} }
} else {
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 { if err := get.Session().Delete(id); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s"}, err) event.AuditErr([]string{ClientIP(c), "session %s"}, err)
} else { } else {
event.AuditDebug([]string{ClientIP(c), "session deleted"}) 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)
}) })
} }

View File

@@ -17,22 +17,24 @@ func GetSession(router *gin.RouterGroup) {
router.GET("/session/:id", func(c *gin.Context) { router.GET("/session/:id", func(c *gin.Context) {
id := clean.ID(c.Param("id")) id := clean.ID(c.Param("id"))
// Check authentication token.
if id == "" { if id == "" {
// Abort if authentication token is missing or empty.
AbortBadRequest(c) AbortBadRequest(c)
return return
} else if id != SessionID(c) {
AbortForbidden(c)
return
} }
conf := get.Config() conf := get.Config()
authToken := AuthToken(c)
// Skip authentication if app is running in public mode. // Skip authentication if app is running in public mode.
var sess *entity.Session var sess *entity.Session
if conf.Public() { if conf.Public() {
sess = get.Session().Public() sess = get.Session().Public()
id = sess.ID
authToken = sess.AuthToken()
} else { } else {
sess = Session(id) sess = Session(authToken)
} }
switch { switch {
@@ -42,7 +44,7 @@ func GetSession(router *gin.RouterGroup) {
case sess.Expired(), sess.ID == "": case sess.Expired(), sess.ID == "":
AbortUnauthorized(c) AbortUnauthorized(c)
return return
case sess.Invalid(): case sess.Invalid(), sess.ID != id && !conf.Public():
AbortForbidden(c) AbortForbidden(c)
return return
} }
@@ -51,18 +53,12 @@ func GetSession(router *gin.RouterGroup) {
sess.RefreshUser() sess.RefreshUser()
// Add session id to response headers. // 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. // Response includes user data, session data, and client config values.
data := gin.H{ response := SessionResponse(authToken, sess, get.Config().ClientSession(sess))
"status": "ok",
"id": sess.ID,
"provider": sess.AuthProvider,
"user": sess.User(),
"data": sess.Data(),
"config": get.Config().ClientSession(sess),
}
c.JSON(http.StatusOK, data) // Return JSON response.
c.JSON(http.StatusOK, response)
}) })
} }

View File

@@ -14,8 +14,8 @@ import (
"github.com/photoprism/photoprism/internal/server/limiter" "github.com/photoprism/photoprism/internal/server/limiter"
) )
// CreateOauthToken creates a new access token and returns it as JSON // CreateOauthToken creates a new access token for clients that
// if the client's credentials have been successfully validated. // authenticate with valid OAuth2 client credentials.
// //
// POST /api/v1/oauth/token // POST /api/v1/oauth/token
func CreateOauthToken(router *gin.RouterGroup) { func CreateOauthToken(router *gin.RouterGroup) {
@@ -60,7 +60,7 @@ func CreateOauthToken(router *gin.RouterGroup) {
limiter.Login.Reserve(clientIP) limiter.Login.Reserve(clientIP)
return return
} else if !client.AuthEnabled { } 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)}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return return
} else if client.AuthMethod != authn.MethodOAuth2.String() { } 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) 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{ data := gin.H{
"access_token": sess.ID, "access_token": sess.AuthToken(),
"token_type": "Bearer", "token_type": sess.AuthTokenType(),
"expires_in": sess.ExpiresIn(), "expires_in": sess.ExpiresIn(),
} }
// Return JSON response.
c.JSON(http.StatusOK, data) c.JSON(http.StatusOK, data)
}) })
} }

View File

@@ -9,21 +9,55 @@ import (
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n" "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) { func TestSession(t *testing.T) {
t.Run("Public", func(t *testing.T) { t.Run("Public", func(t *testing.T) {
assert.Equal(t, session.Public, Session("")) sess := get.Session().Public()
assert.Equal(t, session.Public, Session("638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0")) 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) defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router) CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism"}`) r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism"}`)
log.Debugf("BODY: %s", r.Body.String()) log.Debugf("BODY: %s", r.Body.String())
val2 := gjson.Get(r.Body.String(), "user.Name") val2 := gjson.Get(r.Body.String(), "user.Name")
@@ -46,6 +81,7 @@ func TestCreateSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router) CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": 123, "password": "xxx"}`) r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": 123, "password": "xxx"}`)
assert.Equal(t, http.StatusBadRequest, r.Code) assert.Equal(t, http.StatusBadRequest, r.Code)
}) })
@@ -55,6 +91,7 @@ func TestCreateSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router) CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism", "token": "xxx"}`) r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism", "token": "xxx"}`)
assert.Equal(t, http.StatusNotFound, r.Code) assert.Equal(t, http.StatusNotFound, r.Code)
}) })
@@ -63,9 +100,9 @@ func TestCreateSession(t *testing.T) {
conf.SetAuthMode(config.AuthModePasswd) conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
// CreateSession(router) authToken := AuthenticateUser(app, router, "alice", "Alice123!")
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "xxx"}`, sessId) r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "xxx"}`, authToken)
assert.Equal(t, http.StatusNotFound, r.Code) assert.Equal(t, http.StatusNotFound, r.Code)
}) })
t.Run("VisitorInvalidToken", func(t *testing.T) { t.Run("VisitorInvalidToken", func(t *testing.T) {
@@ -74,6 +111,7 @@ func TestCreateSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router) CreateSession(router)
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "xxx"}`, "345346") r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "xxx"}`, "345346")
assert.Equal(t, http.StatusNotFound, r.Code) assert.Equal(t, http.StatusNotFound, r.Code)
}) })
@@ -82,13 +120,16 @@ func TestCreateSession(t *testing.T) {
conf.SetAuthMode(config.AuthModePasswd) conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
sessId := AuthenticateUser(app, router, "alice", "Alice123!") authToken := AuthenticateUser(app, router, "alice", "Alice123!")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "1jxf3jfn2k"}`, sessId)
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "1jxf3jfn2k"}`, authToken)
assert.Equal(t, http.StatusOK, r.Code) assert.Equal(t, http.StatusOK, r.Code)
}) })
t.Run("PublicValidToken", func(t *testing.T) { t.Run("PublicValidToken", func(t *testing.T) {
app, router, _ := NewApiTest() app, router, _ := NewApiTest()
CreateSession(router) CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism", "token": "1jxf3jfn2k"}`) r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism", "token": "1jxf3jfn2k"}`)
assert.Equal(t, http.StatusOK, r.Code) assert.Equal(t, http.StatusOK, r.Code)
}) })
@@ -128,6 +169,7 @@ func TestCreateSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router) CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "bob", "password": "Bobbob123!"}`) r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "bob", "password": "Bobbob123!"}`)
userEmail := gjson.Get(r.Body.String(), "user.Email") userEmail := gjson.Get(r.Body.String(), "user.Email")
userName := gjson.Get(r.Body.String(), "user.Name") userName := gjson.Get(r.Body.String(), "user.Name")
@@ -141,6 +183,7 @@ func TestCreateSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router) CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "bob", "password": "helloworld"}`) r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "bob", "password": "helloworld"}`)
val := gjson.Get(r.Body.String(), "error") val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrInvalidCredentials), val.String()) assert.Equal(t, i18n.Msg(i18n.ErrInvalidCredentials), val.String())
@@ -155,10 +198,10 @@ func TestGetSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
GetSession(router) GetSession(router)
authToken := AuthenticateAdmin(app, router)
sessId := AuthenticateAdmin(app, router) r := PerformRequest(app, http.MethodGet, "/api/v1/session/"+rnd.SessionID(authToken))
r := PerformRequest(app, http.MethodGet, "/api/v1/session/"+sessId) assert.Equal(t, http.StatusUnauthorized, r.Code)
assert.Equal(t, http.StatusForbidden, r.Code)
}) })
t.Run("AdminAuthenticatedRequest", func(t *testing.T) { t.Run("AdminAuthenticatedRequest", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
@@ -166,9 +209,10 @@ func TestGetSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
GetSession(router) GetSession(router)
authToken := AuthenticateAdmin(app, router)
sessId := AuthenticateAdmin(app, router) t.Logf("Session ID: %s", authToken)
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/session/"+sessId, sessId) r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/session/"+rnd.SessionID(authToken), authToken)
assert.Equal(t, http.StatusOK, r.Code) assert.Equal(t, http.StatusOK, r.Code)
}) })
} }
@@ -180,10 +224,12 @@ func TestDeleteSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
DeleteSession(router) 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) assert.Equal(t, http.StatusOK, r.Code)
}) })
t.Run("AdminAuthenticatedRequest", func(t *testing.T) { t.Run("AdminAuthenticatedRequest", func(t *testing.T) {
@@ -192,10 +238,9 @@ func TestDeleteSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
DeleteSession(router) DeleteSession(router)
authToken := AuthenticateAdmin(app, router)
sessId := AuthenticateAdmin(app, router) r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(authToken), authToken)
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+sessId, sessId)
assert.Equal(t, http.StatusOK, r.Code) assert.Equal(t, http.StatusOK, r.Code)
}) })
t.Run("UserWithoutAuthentication", func(t *testing.T) { t.Run("UserWithoutAuthentication", func(t *testing.T) {
@@ -204,11 +249,10 @@ func TestDeleteSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
DeleteSession(router) DeleteSession(router)
bobToken := "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"
sessId := AuthenticateUser(app, router, "alice", "Alice123!") r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(bobToken))
assert.Equal(t, http.StatusUnauthorized, r.Code)
r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+sessId)
assert.Equal(t, http.StatusOK, r.Code)
}) })
t.Run("UserAuthenticatedRequest", func(t *testing.T) { t.Run("UserAuthenticatedRequest", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
@@ -216,22 +260,45 @@ func TestDeleteSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
DeleteSession(router) DeleteSession(router)
authToken := AuthenticateUser(app, router, "alice", "Alice123!")
sessId := AuthenticateUser(app, router, "alice", "Alice123!") r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(authToken), authToken)
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+sessId, sessId)
assert.Equal(t, http.StatusOK, r.Code) 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) { t.Run("InvalidSession", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd) conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
sessId := "638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0"
DeleteSession(router) DeleteSession(router)
authToken := AuthenticateUser(app, router, "alice", "Alice123!")
deleteToken := "638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0"
r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+sessId) r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(deleteToken), authToken)
assert.Equal(t, http.StatusOK, r.Code) assert.Equal(t, http.StatusForbidden, r.Code)
}) })
} }

View File

@@ -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") 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) fmt.Printf("\n%s\n", result)
} }

View File

@@ -12,6 +12,7 @@ import (
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/list" "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"` UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID" yaml:"UserUID,omitempty"`
UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"` UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"`
user *User `gorm:"-"` user *User `gorm:"-"`
authToken string `gorm:"-"`
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"` AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"`
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,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"` 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. // NewSession creates a new session using the maxAge and timeout in seconds.
func NewSession(lifetime, timeout int64) (m *Session) { func NewSession(lifetime, timeout int64) (m *Session) {
created := TimeStamp() m = &Session{}
m = &Session{ m.Regenerate()
ID: rnd.SessionID(),
RefID: rnd.RefID(SessionPrefix),
CreatedAt: created,
UpdatedAt: created,
}
if lifetime > 0 { if lifetime > 0 {
m.SessExpires = created.Unix() + lifetime m.SessExpires = TimeStamp().Unix() + lifetime
} }
if timeout > 0 { if timeout > 0 {
@@ -142,9 +139,27 @@ func FindSessionByRefID(refId string) *Session {
return m return m
} }
// RegenerateID regenerated the random session ID. // AuthToken returns the secret client authentication token.
func (m *Session) RegenerateID() *Session { func (m *Session) AuthToken() string {
if m.ID == "" { 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. // Do not delete the old session if no ID is set yet.
} else if err := m.Delete(); err != nil { } else if err := m.Delete(); err != nil {
event.AuditErr([]string{m.IP(), "session %s", "failed to delete", "%s"}, m.RefID, err) 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() generated := TimeStamp()
m.ID = rnd.SessionID() m.SetAuthToken(rnd.AuthToken())
m.RefID = rnd.RefID(SessionPrefix) m.RefID = rnd.RefID(SessionPrefix)
m.CreatedAt = generated m.CreatedAt = generated
m.UpdatedAt = generated m.UpdatedAt = generated
@@ -222,7 +237,7 @@ func (m *Session) BeforeCreate(scope *gorm.Scope) error {
return nil return nil
} }
m.ID = rnd.SessionID() m.Regenerate()
return scope.SetColumn("ID", m.ID) return scope.SetColumn("ID", m.ID)
} }

View File

@@ -15,13 +15,17 @@ import (
var sessionCacheExpiration = 15 * time.Minute var sessionCacheExpiration = 15 * time.Minute
var sessionCache = gc.New(sessionCacheExpiration, 5*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) { func FindSession(id string) (*Session, error) {
found := &Session{} found := &Session{}
// Valid id?
if !rnd.IsSessionID(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. // Find the session in the cache with a fallback to the database.

View File

@@ -4,9 +4,9 @@ import (
"testing" "testing"
"time" "time"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/rnd"
) )
func TestFlushSessionCache(t *testing.T) { 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) { func TestFindSession(t *testing.T) {
t.Run("EmptyID", func(t *testing.T) { t.Run("EmptyID", func(t *testing.T) {
if _, err := FindSession(""); err == nil { if _, err := FindSession(""); err == nil {
@@ -27,54 +93,54 @@ func TestFindSession(t *testing.T) {
} }
}) })
t.Run("NotFound", func(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.Fatal("error expected")
} }
}) })
t.Run("Alice", func(t *testing.T) { 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) t.Fatal(err)
} else { } 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").UserUID, result.UserUID)
assert.Equal(t, UserFixtures.Pointer("alice").UserName, result.UserName) 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) t.Fatal(err)
} else { } 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").UserUID, cached.UserUID)
assert.Equal(t, UserFixtures.Pointer("alice").UserName, cached.UserName) assert.Equal(t, UserFixtures.Pointer("alice").UserName, cached.UserName)
} }
}) })
t.Run("Bob", func(t *testing.T) { 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) t.Fatal(err)
} else { } 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").UserUID, result.UserUID)
assert.Equal(t, UserFixtures.Pointer("bob").UserName, result.UserName) 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) t.Fatal(err)
} else { } 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").UserUID, cached.UserUID)
assert.Equal(t, UserFixtures.Pointer("bob").UserName, cached.UserName) assert.Equal(t, UserFixtures.Pointer("bob").UserName, cached.UserName)
} }
}) })
t.Run("Visitor", func(t *testing.T) { 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) t.Fatal(err)
} else { } 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.UserUID, result.UserUID)
assert.Equal(t, Visitor.UserName, result.UserName) 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) t.Fatal(err)
} else { } 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.UserUID, cached.UserUID)
assert.Equal(t, Visitor.UserName, cached.UserName) assert.Equal(t, Visitor.UserName, cached.UserName)
} }
@@ -84,26 +150,26 @@ func TestFindSession(t *testing.T) {
func TestCacheSession(t *testing.T) { func TestCacheSession(t *testing.T) {
t.Run("bob", func(t *testing.T) { t.Run("bob", func(t *testing.T) {
sessionCache.Flush() sessionCache.Flush()
r, b := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") r, b := sessionCache.Get(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"))
assert.Empty(t, r) assert.Empty(t, r)
assert.False(t, b) assert.False(t, b)
bob := FindSessionByRefID("sessxkkcabce") bob := FindSessionByRefID("sessxkkcabce")
CacheSession(bob, time.Hour) CacheSession(bob, time.Hour)
r2, b2 := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") r2, b2 := sessionCache.Get(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"))
assert.NotEmpty(t, r2) assert.NotEmpty(t, r2)
assert.True(t, b2) assert.True(t, b2)
sessionCache.Flush() sessionCache.Flush()
r3, b3 := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") r3, b3 := sessionCache.Get(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"))
assert.Empty(t, r3) assert.Empty(t, r3)
assert.False(t, b3) assert.False(t, b3)
}) })
t.Run("duration 0", func(t *testing.T) { 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.Empty(t, r)
assert.False(t, b) assert.False(t, b)
alice := FindSessionByRefID("sessxkkcabcd") alice := FindSessionByRefID("sessxkkcabcd")
CacheSession(alice, 0) CacheSession(alice, 0)
r2, b2 := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0") r2, b2 := sessionCache.Get(rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"))
assert.NotEmpty(t, r2) assert.NotEmpty(t, r2)
assert.True(t, b2) assert.True(t, b2)
sessionCache.Flush() sessionCache.Flush()
@@ -122,28 +188,30 @@ func TestCacheSession(t *testing.T) {
} }
func TestDeleteSession(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) CacheSession(m, time.Hour)
r, _ := sessionCache.Get("77be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") r, _ := sessionCache.Get(id)
assert.NotEmpty(t, r) assert.NotEmpty(t, r)
DeleteSession(m) DeleteSession(m)
r2, _ := sessionCache.Get("77be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") r2, _ := sessionCache.Get(id)
assert.Empty(t, r2) assert.Empty(t, r2)
} }
func TestDeleteFromSessionCache(t *testing.T) { func TestDeleteFromSessionCache(t *testing.T) {
id := rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1")
sessionCache.Flush() sessionCache.Flush()
bob := FindSessionByRefID("sessxkkcabce") bob := FindSessionByRefID("sessxkkcabce")
CacheSession(bob, time.Hour) CacheSession(bob, time.Hour)
r, b := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") r, b := sessionCache.Get(id)
assert.NotEmpty(t, r) assert.NotEmpty(t, r)
assert.True(t, b) assert.True(t, b)
DeleteFromSessionCache("") DeleteFromSessionCache("")
r2, b2 := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") r2, b2 := sessionCache.Get(id)
assert.NotEmpty(t, r2) assert.NotEmpty(t, r2)
assert.True(t, b2) assert.True(t, b2)
DeleteFromSessionCache("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") DeleteFromSessionCache(id)
r3, b3 := sessionCache.Get("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1") r3, b3 := sessionCache.Get(id)
assert.Empty(t, r3) assert.Empty(t, r3)
assert.False(t, b3) assert.False(t, b3)
} }

View File

@@ -3,6 +3,7 @@ package entity
import ( import (
"github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
) )
type SessionMap map[string]Session type SessionMap map[string]Session
@@ -25,7 +26,8 @@ func (m SessionMap) Pointer(name string) *Session {
var SessionFixtures = SessionMap{ var SessionFixtures = SessionMap{
"alice": { "alice": {
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"),
RefID: "sessxkkcabcd", RefID: "sessxkkcabcd",
SessTimeout: UnixDay * 3, SessTimeout: UnixDay * 3,
SessExpires: UnixTime() + UnixWeek, SessExpires: UnixTime() + UnixWeek,
@@ -34,7 +36,8 @@ var SessionFixtures = SessionMap{
UserName: UserFixtures.Pointer("alice").UserName, UserName: UserFixtures.Pointer("alice").UserName,
}, },
"alice_token": { "alice_token": {
ID: "bb8658e779403ae524a188712470060f050054324a8b104e", authToken: "bb8658e779403ae524a188712470060f050054324a8b104e",
ID: rnd.SessionID("bb8658e779403ae524a188712470060f050054324a8b104e"),
RefID: "sess34q3hael", RefID: "sess34q3hael",
SessTimeout: -1, SessTimeout: -1,
SessExpires: UnixTime() + UnixDay, SessExpires: UnixTime() + UnixDay,
@@ -47,7 +50,8 @@ var SessionFixtures = SessionMap{
UserName: UserFixtures.Pointer("alice").UserName, UserName: UserFixtures.Pointer("alice").UserName,
}, },
"alice_token_scope": { "alice_token_scope": {
ID: "778f0f7d80579a072836c65b786145d6e0127505194cc51e", authToken: "778f0f7d80579a072836c65b786145d6e0127505194cc51e",
ID: rnd.SessionID("778f0f7d80579a072836c65b786145d6e0127505194cc51e"),
RefID: "sessjr0ge18d", RefID: "sessjr0ge18d",
SessTimeout: 0, SessTimeout: 0,
SessExpires: UnixTime() + UnixDay, SessExpires: UnixTime() + UnixDay,
@@ -61,7 +65,8 @@ var SessionFixtures = SessionMap{
DownloadToken: "64ydcbom", DownloadToken: "64ydcbom",
}, },
"bob": { "bob": {
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1", authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"),
RefID: "sessxkkcabce", RefID: "sessxkkcabce",
SessTimeout: UnixDay * 3, SessTimeout: UnixDay * 3,
SessExpires: UnixTime() + UnixWeek, SessExpires: UnixTime() + UnixWeek,
@@ -70,7 +75,8 @@ var SessionFixtures = SessionMap{
UserName: UserFixtures.Pointer("bob").UserName, UserName: UserFixtures.Pointer("bob").UserName,
}, },
"unauthorized": { "unauthorized": {
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2", authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2"),
RefID: "sessxkkcabcf", RefID: "sessxkkcabcf",
SessTimeout: UnixDay * 3, SessTimeout: UnixDay * 3,
SessExpires: UnixTime() + UnixWeek, SessExpires: UnixTime() + UnixWeek,
@@ -79,7 +85,8 @@ var SessionFixtures = SessionMap{
UserName: UserFixtures.Pointer("unauthorized").UserName, UserName: UserFixtures.Pointer("unauthorized").UserName,
}, },
"visitor": { "visitor": {
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3", authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"),
RefID: "sessxkkcabcg", RefID: "sessxkkcabcg",
SessTimeout: UnixDay * 3, SessTimeout: UnixDay * 3,
SessExpires: UnixTime() + UnixWeek, SessExpires: UnixTime() + UnixWeek,
@@ -93,7 +100,8 @@ var SessionFixtures = SessionMap{
}, },
}, },
"visitor_token_metrics": { "visitor_token_metrics": {
ID: "4ebe1048a7384e1e6af2930b5b6f29795ffab691df47a488", authToken: "4ebe1048a7384e1e6af2930b5b6f29795ffab691df47a488",
ID: rnd.SessionID("4ebe1048a7384e1e6af2930b5b6f29795ffab691df47a488"),
RefID: "sessaae5cxun", RefID: "sessaae5cxun",
SessTimeout: 0, SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek, SessExpires: UnixTime() + UnixWeek,
@@ -105,7 +113,8 @@ var SessionFixtures = SessionMap{
UserName: Visitor.UserName, UserName: Visitor.UserName,
}, },
"friend": { "friend": {
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4", authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4"),
RefID: "sessxkkcabch", RefID: "sessxkkcabch",
SessTimeout: UnixDay * 3, SessTimeout: UnixDay * 3,
SessExpires: UnixTime() + UnixWeek, SessExpires: UnixTime() + UnixWeek,
@@ -114,7 +123,8 @@ var SessionFixtures = SessionMap{
UserName: UserFixtures.Pointer("friend").UserName, UserName: UserFixtures.Pointer("friend").UserName,
}, },
"token_metrics": { "token_metrics": {
ID: "9d8b8801ffa23eb52e08ca7766283799ddfd8dd368208a9b", authToken: "9d8b8801ffa23eb52e08ca7766283799ddfd8dd368208a9b",
ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368208a9b"),
RefID: "sessgh6gjuo1", RefID: "sessgh6gjuo1",
SessTimeout: 0, SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek, SessExpires: UnixTime() + UnixWeek,
@@ -128,7 +138,8 @@ var SessionFixtures = SessionMap{
DownloadToken: "vgln2ffb", DownloadToken: "vgln2ffb",
}, },
"token_settings": { "token_settings": {
ID: "3f9684f7d3dd3d5b84edd43289c7fb5ca32ee73bd0233237", authToken: "3f9684f7d3dd3d5b84edd43289c7fb5ca32ee73bd0233237",
ID: rnd.SessionID("3f9684f7d3dd3d5b84edd43289c7fb5ca32ee73bd0233237"),
RefID: "sessyugn54so", RefID: "sessyugn54so",
SessTimeout: 0, SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek, SessExpires: UnixTime() + UnixWeek,

View File

@@ -10,7 +10,8 @@ func TestSessionMap_Get(t *testing.T) {
t.Run("Alice", func(t *testing.T) { t.Run("Alice", func(t *testing.T) {
r := SessionFixtures.Get("alice") r := SessionFixtures.Get("alice")
assert.Equal(t, "alice", r.UserName) 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) assert.IsType(t, Session{}, r)
}) })
@@ -25,7 +26,8 @@ func TestSessionMap_Get(t *testing.T) {
func TestSessionMap_Pointer(t *testing.T) { func TestSessionMap_Pointer(t *testing.T) {
t.Run("Alice", func(t *testing.T) { t.Run("Alice", func(t *testing.T) {
r := SessionFixtures.Pointer("alice") 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.Equal(t, "alice", r.UserName)
assert.IsType(t, &Session{}, r) assert.IsType(t, &Session{}, r)
}) })

View File

@@ -98,7 +98,7 @@ func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) {
// Login credentials provided? // Login credentials provided?
if f.HasCredentials() { if f.HasCredentials() {
if m.IsRegistered() { if m.IsRegistered() {
m.RegenerateID() m.Regenerate()
} }
user, provider, err = Auth(f, m, c) user, provider, err = Auth(f, m, c)

View File

@@ -4,10 +4,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/internal/server/header"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
) )
@@ -15,6 +16,7 @@ func TestNewSession(t *testing.T) {
t.Run("NoSessionData", func(t *testing.T) { t.Run("NoSessionData", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6) m := NewSession(UnixDay, UnixHour*6)
assert.True(t, rnd.IsAuthToken(m.AuthToken()))
assert.True(t, rnd.IsSessionID(m.ID)) assert.True(t, rnd.IsSessionID(m.ID))
assert.False(t, m.CreatedAt.IsZero()) assert.False(t, m.CreatedAt.IsZero())
assert.False(t, m.UpdatedAt.IsZero()) assert.False(t, m.UpdatedAt.IsZero())
@@ -27,6 +29,7 @@ func TestNewSession(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6) m := NewSession(UnixDay, UnixHour*6)
m.SetData(NewSessionData()) m.SetData(NewSessionData())
assert.True(t, rnd.IsAuthToken(m.AuthToken()))
assert.True(t, rnd.IsSessionID(m.ID)) assert.True(t, rnd.IsSessionID(m.ID))
assert.False(t, m.CreatedAt.IsZero()) assert.False(t, m.CreatedAt.IsZero())
assert.False(t, m.UpdatedAt.IsZero()) assert.False(t, m.UpdatedAt.IsZero())
@@ -41,6 +44,7 @@ func TestNewSession(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6) m := NewSession(UnixDay, UnixHour*6)
m.SetData(data) m.SetData(data)
assert.True(t, rnd.IsAuthToken(m.AuthToken()))
assert.True(t, rnd.IsSessionID(m.ID)) assert.True(t, rnd.IsSessionID(m.ID))
assert.False(t, m.CreatedAt.IsZero()) assert.False(t, m.CreatedAt.IsZero())
assert.False(t, m.UpdatedAt.IsZero()) assert.False(t, m.UpdatedAt.IsZero())
@@ -116,42 +120,80 @@ func TestFindSessionByRefID(t *testing.T) {
}) })
} }
func TestSession_RegenerateID(t *testing.T) { func TestSession_Regenerate(t *testing.T) {
t.Run("Success", func(t *testing.T) { t.Run("NewSession", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour) m := NewSession(UnixDay, UnixHour)
initialID := m.ID initialID := m.ID
m.RegenerateID() m.Regenerate()
finalID := m.ID finalID := m.ID
assert.NotEqual(t, initialID, finalID) assert.NotEqual(t, initialID, finalID)
}) })
t.Run("Empty ID", func(t *testing.T) { t.Run("Empty", func(t *testing.T) {
m := Session{ID: ""} m := Session{ID: ""}
initialID := m.ID initialID := m.ID
m.RegenerateID() m.Regenerate()
finalID := m.ID finalID := m.ID
assert.NotEqual(t, initialID, finalID) 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"} m := Session{ID: "1234567"}
initialID := m.ID initialID := m.ID
m.RegenerateID() m.Regenerate()
finalID := m.ID finalID := m.ID
assert.NotEqual(t, initialID, finalID) 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) { func TestSession_Create(t *testing.T) {
t.Run("Success", func(t *testing.T) { t.Run("Success", func(t *testing.T) {
m := FindSessionByRefID("sessxkkcxxxx") m := FindSessionByRefID("sessxkkcxxxx")
assert.Empty(t, m) assert.Empty(t, m)
s := &Session{ s := &Session{
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7xxx",
UserName: "charles", UserName: "charles",
SessExpires: UnixDay * 3, SessExpires: UnixDay * 3,
SessTimeout: UnixTime() + UnixWeek, SessTimeout: UnixTime() + UnixWeek,
RefID: "sessxkkcxxxx", RefID: "sessxkkcxxxx",
} }
s.SetAuthToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7xxx")
err := s.Create() err := s.Create()
if err != nil { if err != nil {
@@ -162,35 +204,44 @@ func TestSession_Create(t *testing.T) {
assert.Equal(t, "charles", m2.UserName) assert.Equal(t, "charles", m2.UserName)
}) })
t.Run("Invalid RefID", func(t *testing.T) { t.Run("Invalid RefID", func(t *testing.T) {
m, _ := FindSession("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7111") authToken := "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7111"
id := rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7111")
m, _ := FindSession(id)
assert.Empty(t, m) assert.Empty(t, m)
s := &Session{ s := &Session{
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7111",
UserName: "charles", UserName: "charles",
SessExpires: UnixDay * 3, SessExpires: UnixDay * 3,
SessTimeout: UnixTime() + UnixWeek, SessTimeout: UnixTime() + UnixWeek,
RefID: "123", RefID: "123",
} }
s.SetAuthToken(authToken)
err := s.Create() err := s.Create()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
m2, _ := FindSession("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7111") m2, _ := FindSession(id)
assert.NotEqual(t, "123", m2.RefID) assert.NotEqual(t, "123", m2.RefID)
}) })
t.Run("ID already exists", func(t *testing.T) { t.Run("ID already exists", func(t *testing.T) {
authToken := "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"
s := &Session{ s := &Session{
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0",
UserName: "charles", UserName: "charles",
SessExpires: UnixDay * 3, SessExpires: UnixDay * 3,
SessTimeout: UnixTime() + UnixWeek, SessTimeout: UnixTime() + UnixWeek,
RefID: "sessxkkcxxxx", RefID: "sessxkkcxxxx",
} }
s.SetAuthToken(authToken)
err := s.Create() err := s.Create()
assert.Error(t, err) assert.Error(t, err)
}) })
@@ -201,13 +252,14 @@ func TestSession_Save(t *testing.T) {
m := FindSessionByRefID("sessxkkcxxxy") m := FindSessionByRefID("sessxkkcxxxy")
assert.Empty(t, m) assert.Empty(t, m)
s := &Session{ s := &Session{
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7xxy",
UserName: "chris", UserName: "chris",
SessExpires: UnixDay * 3, SessExpires: UnixDay * 3,
SessTimeout: UnixTime() + UnixWeek, SessTimeout: UnixTime() + UnixWeek,
RefID: "sessxkkcxxxy", RefID: "sessxkkcxxxy",
} }
s.SetAuthToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7xxy")
err := s.Save() err := s.Save()
if err != nil { if err != nil {

View File

@@ -1766,7 +1766,7 @@ func TestUser_DeleteSessions(t *testing.T) {
t.Run("alice", func(t *testing.T) { t.Run("alice", func(t *testing.T) {
m := FindLocalUser("alice") 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{})) 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, preview, Admin.PreviewToken)
assert.NotEqual(t, download, Admin.DownloadToken) assert.NotEqual(t, download, Admin.DownloadToken)
}) })
} }

View File

@@ -14,8 +14,10 @@ func Session(id string) (result entity.Session, err error) {
return result, fmt.Errorf("invalid session id") return result, fmt.Errorf("invalid session id")
} else if rnd.IsRefID(id) { } else if rnd.IsRefID(id) {
err = Db().Where("ref_id = ?", id).First(&result).Error err = Db().Where("ref_id = ?", id).First(&result).Error
} else { } else if rnd.IsSessionID(id) {
err = Db().Where("id LIKE ?", id).First(&result).Error err = Db().Where("id LIKE ?", id).First(&result).Error
} else {
err = Db().Where("id LIKE ?", rnd.SessionID(id)).First(&result).Error
} }
return result, err 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()) stmt = stmt.Where("sess_expires > 0 AND sess_expires < ?", entity.UnixTime())
} else if rnd.IsSessionID(search) { } else if rnd.IsSessionID(search) {
stmt = stmt.Where("id = ?", 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) { } else if rnd.IsUID(search, entity.UserUID) {
stmt = stmt.Where("user_uid = ?", search) stmt = stmt.Where("user_uid = ?", search)
} else if search != "" { } else if search != "" {

View File

@@ -4,6 +4,8 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/rnd"
) )
func TestSession(t *testing.T) { func TestSession(t *testing.T) {
@@ -22,7 +24,7 @@ func TestSession(t *testing.T) {
} else { } else {
t.Logf("session: %#v", result) t.Logf("session: %#v", result)
assert.NotNil(t, 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, "uqxetse3cy5eo9z2", result.UserUID)
assert.Equal(t, "alice", result.UserName) assert.Equal(t, "alice", result.UserName)
} }
@@ -33,7 +35,7 @@ func TestSession(t *testing.T) {
} else { } else {
t.Logf("session: %#v", result) t.Logf("session: %#v", result)
assert.NotNil(t, 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, "uqxc08w3d0ej2283", result.UserUID)
assert.Equal(t, "bob", result.UserName) assert.Equal(t, "bob", result.UserName)
} }
@@ -72,7 +74,7 @@ func TestSessions(t *testing.T) {
t.Logf("sessions: %#v", results) t.Logf("sessions: %#v", results)
assert.LessOrEqual(t, 1, len(results)) assert.LessOrEqual(t, 1, len(results))
if len(results) > 0 { 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, "uqxetse3cy5eo9z2", results[0].UserUID)
assert.Equal(t, "alice", results[0].UserName) assert.Equal(t, "alice", results[0].UserName)
} }

View File

@@ -6,6 +6,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/rnd"
) )
func TestSessions(t *testing.T) { func TestSessions(t *testing.T) {
@@ -40,7 +41,7 @@ func TestSessions(t *testing.T) {
t.Logf("sessions: %#v", results) t.Logf("sessions: %#v", results)
assert.LessOrEqual(t, 1, len(results)) assert.LessOrEqual(t, 1, len(results))
if len(results) > 0 { 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, "uqxetse3cy5eo9z2", results[0].UserUID)
assert.Equal(t, "alice", results[0].UserName) assert.Equal(t, "alice", results[0].UserName)
} }

View File

@@ -3,4 +3,6 @@ package header
const ( const (
SessionID = "X-Session-ID" SessionID = "X-Session-ID"
Authorization = "Authorization" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization Authorization = "Authorization" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization
BasicAuth = "Basic"
BearerAuth = "Bearer"
) )

View File

@@ -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()
}

View File

@@ -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))
}
}

View File

@@ -52,7 +52,7 @@ func TestSession_Exists(t *testing.T) {
t.Logf("ID: %s", sess.ID) 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)) assert.True(t, s.Exists(sess.ID))
err = s.Delete(sess.ID) err = s.Delete(sess.ID)

View File

@@ -2,24 +2,32 @@ package session
import ( import (
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/rnd"
) )
var Public *entity.Session // PublicAuthToken is a static authentication token used in public mode.
var PublicID = "234200000000000000000000000000000000000000000000" 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. // Public returns a client session for use in public mode.
func (s *Session) Public() *entity.Session { func (s *Session) Public() *entity.Session {
if Public == nil { if public == nil {
// Do nothing. // Do nothing.
} else if !Public.Expired() { } else if !public.Expired() {
return Public return public
} }
Public = entity.NewSession(0, 0) public = entity.NewSession(0, 0)
Public.ID = PublicID public.SetAuthToken(PublicAuthToken)
Public.AuthMethod = "public" public.AuthMethod = "public"
Public.SetUser(&entity.Admin) public.SetUser(&entity.Admin)
Public.CacheDuration(-1) public.CacheDuration(-1)
return Public return public
} }

View File

@@ -14,9 +14,11 @@ import (
func TestSession_Save(t *testing.T) { func TestSession_Save(t *testing.T) {
s := New(config.TestConfig()) 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)) assert.Falsef(t, s.Exists(id), "session %s should not exist", clean.LogQuote(id))
var err error var err error
@@ -32,8 +34,8 @@ func TestSession_Save(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, 48, len(m.ID)) assert.Equal(t, 64, len(m.ID))
assert.Truef(t, s.Exists(m.ID), "session %s should exist", clean.LogQuote(id)) assert.Truef(t, s.Exists(m.ID), "session %s should exist", clean.LogQuote(m.ID))
newData := &entity.SessionData{ newData := &entity.SessionData{
Shares: entity.UIDs{"a000000000000001"}, Shares: entity.UIDs{"a000000000000001"},
@@ -65,5 +67,6 @@ func TestSession_Create(t *testing.T) {
assert.NotEmpty(t, sess) assert.NotEmpty(t, sess)
assert.NotEmpty(t, sess.ID) assert.NotEmpty(t, sess.ID)
assert.NotEmpty(t, sess.RefID) assert.NotEmpty(t, sess.RefID)
assert.True(t, rnd.IsAuthToken(sess.AuthToken()))
assert.True(t, rnd.IsSessionID(sess.ID)) assert.True(t, rnd.IsSessionID(sess.ID))
} }

View File

@@ -6,8 +6,8 @@ import (
"log" "log"
) )
// SessionID returns a new session id. // AuthToken returns a new session id.
func SessionID() string { func AuthToken() string {
b := make([]byte, 24) b := make([]byte, 24)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
@@ -17,11 +17,25 @@ func SessionID() string {
return fmt.Sprintf("%x", b) return fmt.Sprintf("%x", b)
} }
// IsSessionID checks if the string is a session id. // IsAuthToken checks if the string is a session id.
func IsSessionID(s string) bool { func IsAuthToken(s string) bool {
if len(s) != 48 { if len(s) != 48 {
return false return false
} }
return IsHex(s) 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)
}

View File

@@ -6,10 +6,37 @@ import (
"github.com/stretchr/testify/assert" "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) { func TestIsSessionID(t *testing.T) {
assert.True(t, IsSessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2")) assert.False(t, IsSessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2"))
assert.True(t, IsSessionID(SessionID())) assert.False(t, IsSessionID(AuthToken()))
assert.True(t, IsSessionID(SessionID())) 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("55785BAC-9H4B-4747-B090-EE123FFEE437"))
assert.False(t, IsSessionID("4B1FEF2D1CF4A5BE38B263E0637EDEAD")) assert.False(t, IsSessionID("4B1FEF2D1CF4A5BE38B263E0637EDEAD"))
assert.False(t, IsSessionID(""))
} }

22
pkg/rnd/sha.go Normal file
View File

@@ -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))
}

67
pkg/rnd/sha_test.go Normal file
View File

@@ -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))
})
}

View File

@@ -39,7 +39,7 @@ func IdType(id string) (Type, byte) {
return TypeSHA1, PrefixNone return TypeSHA1, PrefixNone
case IsRefID(id): case IsRefID(id):
return TypeRefID, PrefixNone return TypeRefID, PrefixNone
case IsSessionID(id): case IsAuthToken(id):
return TypeSessionID, PrefixNone return TypeSessionID, PrefixNone
case ValidateCrcToken(id): case ValidateCrcToken(id):
return TypeCrcToken, PrefixNone return TypeCrcToken, PrefixNone