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,
headers: {
common: {
"X-Session-ID": window.localStorage.getItem("session_id"),
"X-Session-ID": window.localStorage.getItem("authToken"),
"X-Client-Uri": c.jsUri,
"X-Client-Version": c.version,
},

View file

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

View file

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

View file

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

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.

View file

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

View file

@ -4,6 +4,7 @@ import (
"crypto/sha1"
"encoding/base64"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
@ -11,9 +12,9 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
)
// SessionID returns the session ID from the request context,
// or an empty string if there is none.
func SessionID(c *gin.Context) string {
// AuthToken returns the client authentication token from the request context,
// or an empty string if none is found.
func AuthToken(c *gin.Context) string {
// Default is an empty string if no context or ID is set.
if c == nil {
return ""
@ -28,9 +29,9 @@ func SessionID(c *gin.Context) string {
return BearerToken(c)
}
// BearerToken returns the value of the bearer token header, or an empty string if there is none.
// BearerToken returns the client bearer token header value, or an empty string if none is found.
func BearerToken(c *gin.Context) string {
if authType, bearerToken := Authorization(c); authType == "Bearer" && bearerToken != "" {
if authType, bearerToken := Authorization(c); authType == header.BearerAuth && bearerToken != "" {
return bearerToken
}
@ -40,7 +41,9 @@ func BearerToken(c *gin.Context) string {
// Authorization returns the authentication type and token from the authorization request header,
// or an empty string if there is none.
func Authorization(c *gin.Context) (authType, authToken string) {
if s := c.GetHeader(header.Authorization); s == "" {
if c == nil {
return "", ""
} else if s := c.GetHeader(header.Authorization); s == "" {
// Ignore.
} else if t := strings.Split(s, " "); len(t) != 2 {
// Ignore.
@ -51,6 +54,13 @@ func Authorization(c *gin.Context) (authType, authToken string) {
return "", ""
}
// AddRequestAuthorizationHeader adds a bearer token authorization header to a request.
func AddRequestAuthorizationHeader(r *http.Request, authToken string) {
if authToken != "" {
r.Header.Add(header.Authorization, fmt.Sprintf("%s %s", header.BearerAuth, authToken))
}
}
// BasicAuth checks the basic authorization header for credentials and returns them if found.
//
// Note that OAuth 2.0 defines basic authentication differently than RFC 7617, however, this
@ -59,7 +69,7 @@ func Authorization(c *gin.Context) (authType, authToken string) {
func BasicAuth(c *gin.Context) (username, password, cacheKey string) {
authType, authToken := Authorization(c)
if authType != "Basic" || authToken == "" {
if authType != header.BasicAuth || authToken == "" {
return "", "", ""
}

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/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/server/header"
)
type CloseableResponseRecorder struct {
@ -53,7 +55,7 @@ func NewApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config
return app, router, get.Config()
}
// Executes an API request with an empty request body.
// PerformRequest runs an API request with an empty request body.
// See https://medium.com/@craigchilds94/testing-gin-json-responses-1f258ce3b0b1
func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(method, path, nil)
@ -63,7 +65,7 @@ func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecor
return w
}
// Executes an API request with the request body as a string.
// PerformRequestWithBody runs an API request with the request body as a string.
func PerformRequestWithBody(r http.Handler, method, path, body string) *httptest.ResponseRecorder {
reader := strings.NewReader(body)
req, _ := http.NewRequest(method, path, reader)
@ -74,7 +76,7 @@ func PerformRequestWithBody(r http.Handler, method, path, body string) *httptest
return w
}
// Executes an API request with a stream response.
// PerformRequestWithStream runs an API request with a stream response.
func PerformRequestWithStream(r http.Handler, method, path string) *CloseableResponseRecorder {
req, _ := http.NewRequest(method, path, nil)
w := &CloseableResponseRecorder{httptest.NewRecorder(), make(chan bool, 1)}
@ -83,3 +85,49 @@ func PerformRequestWithStream(r http.Handler, method, path string) *CloseableRes
return w
}
// AuthenticateAdmin Register session routes and returns valid SessionId.
// Call this func after registering other routes and before performing other requests.
func AuthenticateAdmin(app *gin.Engine, router *gin.RouterGroup) (authToken string) {
return AuthenticateUser(app, router, "admin", "photoprism")
}
// AuthenticateUser Register session routes and returns valid SessionId.
// Call this func after registering other routes and before performing other requests.
func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, name string, password string) (authToken string) {
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", form.AsJson(form.Login{
UserName: name,
Password: password,
}))
authToken = r.Header().Get(header.SessionID)
return
}
// Performs authenticated API request with empty request body.
func AuthenticatedRequest(r http.Handler, method, path, authToken string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(method, path, nil)
AddRequestAuthorizationHeader(req, authToken)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// Performs an authenticated API request containing the request body as a string.
func AuthenticatedRequestWithBody(r http.Handler, method, path, body string, authToken string) *httptest.ResponseRecorder {
reader := strings.NewReader(body)
req, _ := http.NewRequest(method, path, reader)
AddRequestAuthorizationHeader(req, authToken)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}

View file

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

View file

@ -20,7 +20,7 @@ const (
// PublishPhotoEvent publishes updated photo data after changes have been made.
func PublishPhotoEvent(ev EntityEvent, uid string, c *gin.Context) {
if result, _, err := search.Photos(form.SearchPhotos{UID: uid, Merged: true}); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "%s photo %s", "%s"}, SessionID(c), string(ev), uid, err)
event.AuditErr([]string{ClientIP(c), "session %s", "%s photo %s", "%s"}, AuthToken(c), string(ev), uid, err)
} else {
event.PublishEntities("photos", string(ev), result)
}
@ -30,7 +30,7 @@ func PublishPhotoEvent(ev EntityEvent, uid string, c *gin.Context) {
func PublishAlbumEvent(ev EntityEvent, uid string, c *gin.Context) {
f := form.SearchAlbums{UID: uid}
if result, err := search.Albums(f); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "%s album %s", "%s"}, SessionID(c), string(ev), uid, err)
event.AuditErr([]string{ClientIP(c), "session %s", "%s album %s", "%s"}, AuthToken(c), string(ev), uid, err)
} else {
event.PublishEntities("albums", string(ev), result)
}
@ -40,7 +40,7 @@ func PublishAlbumEvent(ev EntityEvent, uid string, c *gin.Context) {
func PublishLabelEvent(ev EntityEvent, uid string, c *gin.Context) {
f := form.SearchLabels{UID: uid}
if result, err := search.Labels(f); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "%s label %s", "%s"}, SessionID(c), string(ev), uid, err)
event.AuditErr([]string{ClientIP(c), "session %s", "%s label %s", "%s"}, AuthToken(c), string(ev), uid, err)
} else {
event.PublishEntities("labels", string(ev), result)
}
@ -50,7 +50,7 @@ func PublishLabelEvent(ev EntityEvent, uid string, c *gin.Context) {
func PublishSubjectEvent(ev EntityEvent, uid string, c *gin.Context) {
f := form.SearchSubjects{UID: uid}
if result, err := search.Subjects(f); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "%s subject %s", "%s"}, SessionID(c), string(ev), uid, err)
event.AuditErr([]string{ClientIP(c), "session %s", "%s subject %s", "%s"}, AuthToken(c), string(ev), uid, err)
} else {
event.PublishEntities("subjects", string(ev), result)
}

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
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/pkg/rnd"
)
// Session finds the client session for the given ID or returns nil otherwise.
func Session(id string) *entity.Session {
// Skip authentication if app is running in public mode.
// Session finds the client session for the specified
// auth token, or returns nil if not found.
func Session(authToken string) *entity.Session {
// Skip authentication when running in public mode.
if get.Config().Public() {
return get.Session().Public()
} else if id == "" {
} else if !rnd.IsAuthToken(authToken) {
return nil
}
// Find session or otherwise return nil.
s, err := get.Session().Get(id)
if err != nil {
// Find the session based on the hashed auth
// token used as id, or return nil otherwise.
if s, err := get.Session().Get(rnd.SessionID(authToken)); err != nil {
return nil
} else {
return s
}
}
// SessionResponse returns authentication response data based on the session and client config.
func SessionResponse(authToken string, sess *entity.Session, conf config.ClientConfig) gin.H {
if authToken == "" {
return gin.H{
"status": "ok",
"id": sess.ID,
"expires_in": sess.ExpiresIn(),
"provider": sess.Provider().String(),
"user": sess.User(),
"data": sess.Data(),
"config": conf,
}
} else {
return gin.H{
"status": "ok",
"id": sess.ID,
"access_token": authToken,
"token_type": sess.AuthTokenType(),
"expires_in": sess.ExpiresIn(),
"provider": sess.Provider().String(),
"user": sess.User(),
"data": sess.Data(),
"config": conf,
}
}
}
// SessionDeleteResponse returns a confirmation response for deleted sessions.
func SessionDeleteResponse(authToken string) gin.H {
if authToken == "" {
return gin.H{"status": "ok"}
} else {
return gin.H{"status": "ok", "id": authToken, "access_token": authToken}
}
return s
}

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

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

View file

@ -9,6 +9,7 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
@ -20,38 +21,54 @@ func DeleteSession(router *gin.RouterGroup) {
router.DELETE("/session/:id", func(c *gin.Context) {
id := clean.ID(c.Param("id"))
// Abort if ID is missing.
// Abort if authentication token is missing or empty.
if id == "" {
AbortBadRequest(c)
return
} else if get.Config().Public() {
c.JSON(http.StatusOK, gin.H{"status": "authentication disabled", "id": id})
c.JSON(http.StatusOK, gin.H{"status": "running in public mode", "id": session.PublicAuthToken})
return
}
// Find session by reference ID.
if !rnd.IsRefID(id) {
// Do nothing.
} else if s := Session(SessionID(c)); s == nil {
entity.SessionStatusUnauthorized().Abort(c)
return
} else if !acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
s.Abort(c)
return
} else if ref := entity.FindSessionByRefID(id); ref == nil {
AbortNotFound(c)
return
// Only admins may delete other sessions by reference id.
if rnd.IsRefID(id) {
if s := Session(AuthToken(c)); s == nil {
entity.SessionStatusUnauthorized().Abort(c)
return
} else if s.Abort(c) {
return
} else if !acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
s.Abort(c)
return
} else if ref := entity.FindSessionByRefID(id); ref == nil {
AbortNotFound(c)
return
} else {
id = ref.ID
}
} else {
id = ref.ID
if s := Session(AuthToken(c)); s == nil {
entity.SessionStatusUnauthorized().Abort(c)
return
} else if s.Abort(c) {
return
} else if s.ID != id {
entity.SessionStatusForbidden().Abort(c)
return
}
}
// Delete session by ID.
// Delete session.
if err := get.Session().Delete(id); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s"}, err)
} else {
event.AuditDebug([]string{ClientIP(c), "session deleted"})
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "id": id})
// Response includes the auth token for confirmation.
response := SessionDeleteResponse(id)
// Return JSON response.
c.JSON(http.StatusOK, response)
})
}

View file

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

View file

@ -14,8 +14,8 @@ import (
"github.com/photoprism/photoprism/internal/server/limiter"
)
// CreateOauthToken creates a new access token and returns it as JSON
// if the client's credentials have been successfully validated.
// CreateOauthToken creates a new access token for clients that
// authenticate with valid OAuth2 client credentials.
//
// POST /api/v1/oauth/token
func CreateOauthToken(router *gin.RouterGroup) {
@ -60,7 +60,7 @@ func CreateOauthToken(router *gin.RouterGroup) {
limiter.Login.Reserve(clientIP)
return
} else if !client.AuthEnabled {
event.AuditWarn([]string{clientIP, "client %s", "create access token", "authentication disabled"}, f.ClientID)
event.AuditWarn([]string{clientIP, "client %s", "create access token", "running in public mode"}, f.ClientID)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
} else if client.AuthMethod != authn.MethodOAuth2.String() {
@ -92,13 +92,14 @@ func CreateOauthToken(router *gin.RouterGroup) {
event.AuditInfo([]string{clientIP, "client %s", "session %s", "access token created"}, f.ClientID, sess.RefID)
}
// Return access token.
// Response includes access token, token type, and token lifetime.
data := gin.H{
"access_token": sess.ID,
"token_type": "Bearer",
"access_token": sess.AuthToken(),
"token_type": sess.AuthTokenType(),
"expires_in": sess.ExpiresIn(),
}
// Return JSON response.
c.JSON(http.StatusOK, data)
})
}

View file

@ -9,21 +9,55 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestSessionID(t *testing.T) {
t.Run("NoContext", func(t *testing.T) {
result := SessionID(nil)
assert.Equal(t, "", result)
})
}
func TestSession(t *testing.T) {
t.Run("Public", func(t *testing.T) {
assert.Equal(t, session.Public, Session(""))
assert.Equal(t, session.Public, Session("638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0"))
sess := get.Session().Public()
assert.Equal(t, sess, Session(""))
assert.Equal(t, sess, Session("638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0"))
})
}
func TestSessionResponse(t *testing.T) {
t.Run("Public", func(t *testing.T) {
sess := get.Session().Public()
conf := get.Config().ClientSession(sess)
// Create response in public mode.
result := SessionResponse(sess.AuthToken(), sess, conf)
// Check response.
assert.Equal(t, "ok", result["status"])
assert.Equal(t, sess.ID, result["id"])
assert.Equal(t, sess.AuthToken(), result["access_token"])
assert.Equal(t, sess.AuthTokenType(), result["token_type"])
assert.Equal(t, sess.ExpiresIn(), result["expires_in"])
assert.Equal(t, sess.Provider().String(), result["provider"])
assert.Equal(t, sess.User(), result["user"])
assert.Equal(t, sess.Data(), result["data"])
assert.Equal(t, conf, result["config"])
})
t.Run("NoAuthToken", func(t *testing.T) {
sess := get.Session().Public()
conf := get.Config().ClientSession(sess)
// Create response without auth token.
result := SessionResponse("", sess, conf)
// Check response.
assert.Equal(t, "ok", result["status"])
assert.Equal(t, sess.ID, result["id"])
assert.Nil(t, result["access_token"])
assert.Nil(t, result["token_type"])
assert.Equal(t, sess.ExpiresIn(), result["expires_in"])
assert.Equal(t, sess.Provider().String(), result["provider"])
assert.Equal(t, sess.User(), result["user"])
assert.Equal(t, sess.Data(), result["data"])
assert.Equal(t, conf, result["config"])
})
}
@ -34,6 +68,7 @@ func TestCreateSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism"}`)
log.Debugf("BODY: %s", r.Body.String())
val2 := gjson.Get(r.Body.String(), "user.Name")
@ -46,6 +81,7 @@ func TestCreateSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": 123, "password": "xxx"}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
@ -55,6 +91,7 @@ func TestCreateSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism", "token": "xxx"}`)
assert.Equal(t, http.StatusNotFound, r.Code)
})
@ -63,9 +100,9 @@ func TestCreateSession(t *testing.T) {
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
// CreateSession(router)
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "xxx"}`, sessId)
authToken := AuthenticateUser(app, router, "alice", "Alice123!")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "xxx"}`, authToken)
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("VisitorInvalidToken", func(t *testing.T) {
@ -74,6 +111,7 @@ func TestCreateSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router)
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "xxx"}`, "345346")
assert.Equal(t, http.StatusNotFound, r.Code)
})
@ -82,13 +120,16 @@ func TestCreateSession(t *testing.T) {
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "1jxf3jfn2k"}`, sessId)
authToken := AuthenticateUser(app, router, "alice", "Alice123!")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "1jxf3jfn2k"}`, authToken)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("PublicValidToken", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism", "token": "1jxf3jfn2k"}`)
assert.Equal(t, http.StatusOK, r.Code)
})
@ -128,6 +169,7 @@ func TestCreateSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "bob", "password": "Bobbob123!"}`)
userEmail := gjson.Get(r.Body.String(), "user.Email")
userName := gjson.Get(r.Body.String(), "user.Name")
@ -141,6 +183,7 @@ func TestCreateSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "bob", "password": "helloworld"}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrInvalidCredentials), val.String())
@ -155,10 +198,10 @@ func TestGetSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
GetSession(router)
authToken := AuthenticateAdmin(app, router)
sessId := AuthenticateAdmin(app, router)
r := PerformRequest(app, http.MethodGet, "/api/v1/session/"+sessId)
assert.Equal(t, http.StatusForbidden, r.Code)
r := PerformRequest(app, http.MethodGet, "/api/v1/session/"+rnd.SessionID(authToken))
assert.Equal(t, http.StatusUnauthorized, r.Code)
})
t.Run("AdminAuthenticatedRequest", func(t *testing.T) {
app, router, conf := NewApiTest()
@ -166,9 +209,10 @@ func TestGetSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
GetSession(router)
authToken := AuthenticateAdmin(app, router)
sessId := AuthenticateAdmin(app, router)
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/session/"+sessId, sessId)
t.Logf("Session ID: %s", authToken)
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/session/"+rnd.SessionID(authToken), authToken)
assert.Equal(t, http.StatusOK, r.Code)
})
}
@ -180,10 +224,12 @@ func TestDeleteSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
DeleteSession(router)
authToken := AuthenticateAdmin(app, router)
sessId := AuthenticateAdmin(app, router)
// f9ae12e95a01bcc7faae6497124cd721eaf13c1dad301dbc
t.Logf("authToken: %s", authToken)
r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+sessId)
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(authToken), authToken)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("AdminAuthenticatedRequest", func(t *testing.T) {
@ -192,10 +238,9 @@ func TestDeleteSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
DeleteSession(router)
authToken := AuthenticateAdmin(app, router)
sessId := AuthenticateAdmin(app, router)
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+sessId, sessId)
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(authToken), authToken)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("UserWithoutAuthentication", func(t *testing.T) {
@ -204,11 +249,10 @@ func TestDeleteSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
DeleteSession(router)
bobToken := "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+sessId)
assert.Equal(t, http.StatusOK, r.Code)
r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(bobToken))
assert.Equal(t, http.StatusUnauthorized, r.Code)
})
t.Run("UserAuthenticatedRequest", func(t *testing.T) {
app, router, conf := NewApiTest()
@ -216,22 +260,45 @@ func TestDeleteSession(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic)
DeleteSession(router)
authToken := AuthenticateUser(app, router, "alice", "Alice123!")
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+sessId, sessId)
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(authToken), authToken)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("AliceSessionAsBob", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
DeleteSession(router)
bobToken := AuthenticateUser(app, router, "bob", "Bobbob123!")
aliceToken := "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(aliceToken), bobToken)
assert.Equal(t, http.StatusForbidden, r.Code)
})
t.Run("BobSessionAsAlice", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
DeleteSession(router)
aliceToken := AuthenticateUser(app, router, "alice", "Alice123!")
bobToken := "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(bobToken), aliceToken)
assert.Equal(t, http.StatusForbidden, r.Code)
})
t.Run("InvalidSession", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
sessId := "638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0"
DeleteSession(router)
authToken := AuthenticateUser(app, router, "alice", "Alice123!")
deleteToken := "638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0"
r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+sessId)
assert.Equal(t, http.StatusOK, r.Code)
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(deleteToken), authToken)
assert.Equal(t, http.StatusForbidden, r.Code)
})
}

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

View file

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

View file

@ -15,13 +15,17 @@ import (
var sessionCacheExpiration = 15 * time.Minute
var sessionCache = gc.New(sessionCacheExpiration, 5*time.Minute)
// FindSession returns an existing session or nil if not found.
// FindSessionByAuthToken finds a session based on the auth token string or returns nil if it does not exist.
func FindSessionByAuthToken(token string) (*Session, error) {
return FindSession(rnd.SessionID(token))
}
// FindSession finds a session based on the id string or returns nil if it does not exist.
func FindSession(id string) (*Session, error) {
found := &Session{}
// Valid id?
if !rnd.IsSessionID(id) {
return found, fmt.Errorf("id %s is invalid", clean.LogQuote(id))
return found, fmt.Errorf("invalid session id")
}
// Find the session in the cache with a fallback to the database.

View file

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

View file

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

View file

@ -10,7 +10,8 @@ func TestSessionMap_Get(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
r := SessionFixtures.Get("alice")
assert.Equal(t, "alice", r.UserName)
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", r.ID)
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", r.AuthToken())
assert.Equal(t, "a3859489780243a78b331bd44f58255b552dee104041a45c0e79b610f63af2e5", r.ID)
assert.IsType(t, Session{}, r)
})
@ -25,7 +26,8 @@ func TestSessionMap_Get(t *testing.T) {
func TestSessionMap_Pointer(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
r := SessionFixtures.Pointer("alice")
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", r.ID)
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", r.AuthToken())
assert.Equal(t, "a3859489780243a78b331bd44f58255b552dee104041a45c0e79b610f63af2e5", r.ID)
assert.Equal(t, "alice", r.UserName)
assert.IsType(t, &Session{}, r)
})

View file

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

View file

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

View file

@ -1766,7 +1766,7 @@ func TestUser_DeleteSessions(t *testing.T) {
t.Run("alice", func(t *testing.T) {
m := FindLocalUser("alice")
assert.Equal(t, 0, m.DeleteSessions([]string{"69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"}))
assert.Equal(t, 0, m.DeleteSessions([]string{rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")}))
assert.Equal(t, 1, m.DeleteSessions([]string{}))
})
}
@ -1792,7 +1792,6 @@ func TestUser_RegenerateTokens(t *testing.T) {
assert.NotEqual(t, preview, Admin.PreviewToken)
assert.NotEqual(t, download, Admin.DownloadToken)
})
}

View file

@ -14,8 +14,10 @@ func Session(id string) (result entity.Session, err error) {
return result, fmt.Errorf("invalid session id")
} else if rnd.IsRefID(id) {
err = Db().Where("ref_id = ?", id).First(&result).Error
} else {
} else if rnd.IsSessionID(id) {
err = Db().Where("id LIKE ?", id).First(&result).Error
} else {
err = Db().Where("id LIKE ?", rnd.SessionID(id)).First(&result).Error
}
return result, err
@ -32,6 +34,8 @@ func Sessions(limit, offset int, sortOrder, search string) (result entity.Sessio
stmt = stmt.Where("sess_expires > 0 AND sess_expires < ?", entity.UnixTime())
} else if rnd.IsSessionID(search) {
stmt = stmt.Where("id = ?", search)
} else if rnd.IsAuthToken(search) {
stmt = stmt.Where("id = ?", rnd.SessionID(search))
} else if rnd.IsUID(search, entity.UserUID) {
stmt = stmt.Where("user_uid = ?", search)
} else if search != "" {

View file

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

View file

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

View file

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

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)
assert.Equal(t, 48, len(sess.ID))
assert.Equal(t, 64, len(sess.ID))
assert.True(t, s.Exists(sess.ID))
err = s.Delete(sess.ID)

View file

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

View file

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

View file

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

View file

@ -6,10 +6,37 @@ import (
"github.com/stretchr/testify/assert"
)
func TestAuthToken(t *testing.T) {
result := AuthToken()
assert.Equal(t, 48, len(result))
assert.True(t, IsAuthToken(result))
assert.True(t, IsHex(result))
}
func TestIsAuthToken(t *testing.T) {
assert.True(t, IsAuthToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2"))
assert.True(t, IsAuthToken(AuthToken()))
assert.True(t, IsAuthToken(AuthToken()))
assert.False(t, IsAuthToken(SessionID(AuthToken())))
assert.False(t, IsAuthToken(SessionID(AuthToken())))
assert.False(t, IsAuthToken("55785BAC-9H4B-4747-B090-EE123FFEE437"))
assert.False(t, IsAuthToken("4B1FEF2D1CF4A5BE38B263E0637EDEAD"))
assert.False(t, IsAuthToken(""))
}
func TestSessionID(t *testing.T) {
result := SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2")
assert.Equal(t, 64, len(result))
assert.Equal(t, "f22383a703805a031a9835c8c6b6dafb793a21e8f33d0b4887b4ec9bd7ac8cd5", result)
}
func TestIsSessionID(t *testing.T) {
assert.True(t, IsSessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2"))
assert.True(t, IsSessionID(SessionID()))
assert.True(t, IsSessionID(SessionID()))
assert.False(t, IsSessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2"))
assert.False(t, IsSessionID(AuthToken()))
assert.False(t, IsSessionID(AuthToken()))
assert.True(t, IsSessionID(SessionID(AuthToken())))
assert.True(t, IsSessionID(SessionID(AuthToken())))
assert.False(t, IsSessionID("55785BAC-9H4B-4747-B090-EE123FFEE437"))
assert.False(t, IsSessionID("4B1FEF2D1CF4A5BE38B263E0637EDEAD"))
assert.False(t, IsSessionID(""))
}

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
case IsRefID(id):
return TypeRefID, PrefixNone
case IsSessionID(id):
case IsAuthToken(id):
return TypeSessionID, PrefixNone
case ValidateCrcToken(id):
return TypeCrcToken, PrefixNone