Account: Generate app password from the UI #808 #4114

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2024-04-08 10:44:43 +02:00
parent 698ffaa896
commit c9213da4e6
21 changed files with 543 additions and 149 deletions

View File

@@ -435,6 +435,31 @@ export default class Session {
});
}
createApp(client_name, scope, expires_in, password) {
if (!this.isUser() || !this.user.Name) {
return Promise.reject();
}
if (!scope) {
scope = "*";
}
return Api.post("oauth/token", {
grant_type: password ? "password" : "session",
client_name: client_name,
scope: scope,
expires_in: expires_in,
username: this.user.Name,
password: password,
}).then((response) => Promise.resolve(response.data));
}
deleteApp(token) {
return Api.post("oauth/revoke", {
token: token,
}).then((response) => Promise.resolve(response.data));
}
onLogout(noRedirect) {
// Delete all authentication and session data.
this.reset();

View File

@@ -44,6 +44,10 @@
color: #c8e3e7!important;
}
#photoprism main .auth-actions .auth-links {
min-height: 42px;
}
/* Auth Form Logo */
#photoprism .auth-login .logo {

View File

@@ -10,19 +10,111 @@
</h3>
</v-flex>
<v-flex xs3 class="text-xs-right">
<v-icon size="28" color="primary">add</v-icon>
<v-icon v-if="action === 'add'" size="28" color="primary">add</v-icon>
<v-icon v-else-if="action === 'copy'" size="28" color="primary">password</v-icon>
<v-icon v-else size="28" color="primary">devices</v-icon>
</v-flex>
</v-layout>
</v-card-title>
<!-- Setup -->
<!-- Confirm -->
<template v-if="confirmAction !== ''">
<v-card-text class="py-0 px-2">
<v-layout wrap align-top>
<v-flex xs12 class="pa-2 body-2">
<translate>To generate a new app-specific password, please enter the name and authorization scope of the application and choose an expiration date:</translate>
<v-flex xs12 class="pa-2 body-1">
<translate>Enter your password to confirm the action and continue:</translate>
</v-flex>
<v-flex xs12 class="pa-2">
<v-text-field
v-model="newApp.client_name"
v-model="password"
:disabled="busy"
name="password"
:type="showPassword ? 'text' : 'password'"
:label="$gettext('Password')"
hide-details
required
autofocus
solo
flat
autocorrect="off"
autocapitalize="none"
autocomplete="current-password"
browser-autocomplete="current-password"
class="input-password text-selectable"
:append-icon="showPassword ? 'visibility' : 'visibility_off'"
prepend-inner-icon="lock"
color="secondary-dark"
@click:append="showPassword = !showPassword"
@keyup.enter.native="onConfirm"
></v-text-field>
</v-flex>
</v-layout>
</v-card-text>
<v-card-actions class="pa-2">
<v-layout row wrap class="pa-2">
<v-flex xs12 text-xs-right>
<v-btn depressed color="secondary-light" class="action-back ml-0" @click.stop="onBack">
<translate>Back</translate>
</v-btn>
<v-btn depressed color="primary-button" :disabled="!password || password.length < 4" class="action-confirm white--text compact mr-0" @click.stop="onConfirm">
<translate>Continue</translate>
</v-btn>
</v-flex>
</v-layout>
</v-card-actions>
</template>
<!-- Copy -->
<template v-else-if="action === 'copy'">
<v-card-text class="py-0 px-2">
<v-layout wrap align-top>
<v-flex xs12 class="pa-2 body-1">
<translate>Please copy the following randomly generated app password and keep it in a safe place, as you will not be able to see it again:</translate>
</v-flex>
<v-flex xs12 class="pa-2">
<v-text-field
v-model="appPassword"
type="text"
hide-details
readonly
solo
flat
autocorrect="off"
autocapitalize="none"
autocomplete="off"
browser-autocomplete="off"
append-icon="content_copy"
class="input-app-password text-selectable"
color="secondary-dark"
@click:append="onCopyAppPassword"
></v-text-field>
</v-flex>
</v-layout>
</v-card-text>
<v-card-actions class="pa-2">
<v-layout row wrap class="pa-2">
<v-flex xs12 text-xs-right>
<v-btn depressed color="secondary-light" class="action-close ml-0" @click.stop="close">
<translate>Close</translate>
</v-btn>
<v-btn v-if="appPasswordCopied" depressed color="primary-button" :disabled="busy" class="action-done white--text compact mr-0" @click.stop="onDone">
<translate>Done</translate>
</v-btn>
<v-btn v-else depressed color="primary-button" class="action-copy white--text compact mr-0" @click.stop="onCopyAppPassword">
<translate>Copy</translate>
</v-btn>
</v-flex>
</v-layout>
</v-card-actions>
</template>
<!-- Add -->
<template v-else-if="action === 'add'">
<v-card-text class="py-0 px-2">
<v-layout wrap align-top>
<v-flex xs12 class="pa-2 body-1">
<translate>To generate a new app-specific password, please enter the name and authorization scope of the application and select an expiration date:</translate>
</v-flex>
<v-flex xs12 class="pa-2">
<v-text-field
v-model="app.client_name"
:disabled="busy"
name="client_name"
type="text"
@@ -40,25 +132,45 @@
></v-text-field>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-select v-model="newApp.scope" hide-details box :disabled="busy" :items="auth.ScopeOptions()" :label="$gettext('Scope')" :menu-props="{ maxHeight: 346 }" color="secondary-dark" background-color="secondary-light" class="input-scope"></v-select>
<v-select v-model="app.scope" hide-details box :disabled="busy" :items="auth.ScopeOptions()" :label="$gettext('Scope')" :menu-props="{ maxHeight: 346 }" color="secondary-dark" background-color="secondary-light" class="input-scope"></v-select>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-select v-model="newApp.lifetime" :disabled="busy" :label="$gettext('Expires')" browser-autocomplete="off" hide-details box flat color="secondary-dark" class="input-expires" item-text="text" item-value="value" :items="options.Expires()"></v-select>
<v-select v-model="app.expires_in" :disabled="busy" :label="$gettext('Expires')" browser-autocomplete="off" hide-details box flat color="secondary-dark" class="input-expires" item-text="text" item-value="value" :items="options.Expires()"></v-select>
</v-flex>
</v-layout>
</v-card-text>
<v-card-actions class="pa-2">
<v-layout row wrap class="pa-2">
<v-flex xs12 text-xs-right>
<v-btn depressed color="secondary-light" class="action-cancel ml-0" @click.stop="onCancel">
<translate>Cancel</translate>
</v-btn>
<v-btn depressed color="primary-button" :disabled="app.client_name === '' || app.scope === ''" class="action-generate white--text compact mr-0" @click.stop="onGenerate">
<translate>Generate</translate>
</v-btn>
</v-flex>
</v-layout>
</v-card-actions>
</template>
<!-- Apps -->
<template v-else>
<v-card-text class="py-0 px-2">
<v-layout wrap align-top>
</v-layout>
</v-card-text>
<v-card-actions class="pa-2">
<v-layout row wrap class="pa-2">
<v-flex xs12 text-xs-right>
<v-btn depressed color="secondary-light" class="action-close ml-0" @click.stop="close">
<translate>Close</translate>
</v-btn>
<v-btn depressed color="primary-button" disabled class="action-generate white--text compact mr-0" @click.stop="close">
<translate>Generate</translate>
<v-btn depressed color="primary-button" class="action-add white--text compact mr-0" @click.stop="onAdd">
<translate>Add</translate>
</v-btn>
</v-flex>
</v-layout>
</v-card-actions>
</template>
</v-card>
</v-form>
</v-dialog>
@@ -90,26 +202,24 @@ export default {
minLength: this.$config.get("passwordLength"),
maxLength: 72,
rtl: this.$rtl,
passwords: [],
action: "",
confirmAction: "",
user: this.$session.getUser(),
newApp: {
grant_type: "session",
password: "",
apps: [],
app: {
client_name: "",
scope: "*",
lifetime: 0,
expires_in: 0,
},
appPassword: "",
appPasswordCopied: false,
};
},
computed: {
page() {
return "setup";
},
},
watch: {
show: function (show) {
if (show) {
this.reset();
this.find();
}
},
},
@@ -131,30 +241,111 @@ export default {
this.$notify.error(this.$gettext("Failed copying to clipboard"));
}
},
reset() {
this.password = "";
this.showPassword = false;
this.updateUser();
onCopyAppPassword() {
this.copyText(this.appPassword);
this.appPasswordCopied = true;
},
updateUser() {
this.$notify.blockUI();
reset(action) {
if (!action) {
action = "apps";
}
this.app = {
client_name: "",
scope: "*",
expires_in: 0,
};
this.action = action;
this.confirmAction = "";
this.appPasswordCopied = false;
console.log("reset", this.action, action);
},
onConfirm() {
if (this.busy) {
return;
}
switch (this.confirmAction) {
case "onGenerate":
this.onGenerate();
}
},
onDone() {
if (this.busy) {
return;
}
this.appPassword = "";
this.reset();
},
onCancel() {
if (this.busy) {
return;
}
this.reset();
},
onBack() {
if (this.busy) {
return;
}
this.confirmAction = "";
},
onAdd() {
if (this.busy) {
return;
}
this.action = "add";
this.confirmAction = "";
},
onGenerate() {
if (this.busy) {
return;
}
if (this.confirmAction === "") {
this.confirmAction = "onGenerate";
return;
}
this.busy = true;
this.$session
.refresh()
.then(() => {
this.user = this.$session.getUser();
.createApp(this.app.client_name, this.app.scope, this.app.expires_in, this.password)
.then((app) => {
this.appPassword = app.access_token;
this.reset("copy");
})
.catch(() => {
this.action = "add";
this.confirmAction = "";
})
.finally(() => {
this.busy = false;
});
},
find() {
this.$notify.blockUI();
this.model
.findApps()
.then((resp) => {
console.log("findApps", resp);
this.apps = resp;
})
.finally(() => {
this.$notify.unblockUI();
});
},
confirm() {
this.close();
},
close() {
if (this.busy) {
return;
}
this.appPassword = "";
this.$emit("close");
},
},

View File

@@ -258,8 +258,8 @@ export default {
isDemo: this.$config.get("demo"),
isPublic: this.$config.get("public"),
code: "",
password: "",
recoveryCodeCopied: false,
password: "",
showPassword: false,
minLength: this.$config.get("passwordLength"),
maxLength: 72,

View File

@@ -287,6 +287,24 @@ export class User extends RestModel {
}
}
findApps() {
if (!this.Name || !this.CanLogin || this.ID < 1) {
return Promise.reject();
}
const params = {
provider: "application",
method: "default",
count: 10000,
offset: 0,
order: "client_name",
};
return Api.get(this.getEntityResource() + "/sessions", {
params,
}).then((response) => Promise.resolve(response.data));
}
static getCollectionResource() {
return "users";
}

View File

@@ -25,6 +25,7 @@ export const Providers = () => {
application: $gettext("Application"),
access_token: $gettext("Access Token"),
password: $gettext("Local"),
oidc: $gettext("OIDC"),
ldap: $gettext("LDAP/AD"),
link: $gettext("Link"),
token: $gettext("Link"),
@@ -51,8 +52,9 @@ export const Methods = () => {
export const Scopes = () => {
return {
"*": $gettext("Full Access"),
read: $gettext("Read Only"),
read: $gettext("Read Access"),
webdav: $gettext("WebDAV"),
metrics: $gettext("Metrics"),
};
};
@@ -64,20 +66,18 @@ export const ScopeOptions = () => {
value: "*",
},
{
text: $gettext("Read Only"),
text: $gettext("Read Access"),
value: "read *",
},
{
text: $gettext("WebDAV"),
value: "webdav",
},
{
text: $gettext("Metrics"),
value: "metrics",
},
/* TODO: Show additional input field so advanced users can specify a custom scope when this option is selected.
{
text: $gettext("Custom"),
value: "~",
},
*/
];
};

View File

@@ -9,12 +9,31 @@
<v-spacer></v-spacer>
<v-layout wrap align-top>
<template v-if="enterCode">
<v-flex xs12 class="pa-2 body-2">
<translate>Please enter a valid verification code to access your account:</translate>
</v-flex>
<v-flex xs12 class="pa-2">
<v-text-field
id="auth-code"
v-model="username"
disabled
type="text"
:label="$gettext('Name')"
hide-details
required
solo
flat
light
autocorrect="off"
autocapitalize="none"
autocomplete="off"
browser-autocomplete="off"
background-color="grey lighten-5"
class="input-username text-selectable"
color="primary"
prepend-inner-icon="person"
></v-text-field>
</v-flex>
<v-flex xs12 class="px-2 py-1">
<v-text-field
id="one-time-code"
ref="code"
v-model="code"
:disabled="loading"
name="code"
@@ -27,6 +46,7 @@
solo
flat
light
autofocus
autocorrect="off"
autocapitalize="none"
autocomplete="one-time-code"
@@ -38,9 +58,6 @@
@keyup.enter.native="onLogin"
></v-text-field>
</v-flex>
<v-flex xs12 class="px-2 pt-2 pb-0 body-1">
<translate>Can't access your authenticator app or device? Enter your recovery code or ask for assistance.</translate>
</v-flex>
</template>
<template v-else>
<v-flex xs12 class="pa-2">
@@ -109,7 +126,10 @@
<v-icon v-else right dark>navigate_next</v-icon>
</v-btn>
</div>
<div v-if="passwordResetUri" class="auth-links text-xs-center opacity-80">
<div v-if="enterCode" class="auth-links text-xs-center opacity-80">
<translate>Can't access your authenticator app or device? Enter your recovery code or ask for assistance.</translate>
</div>
<div v-else-if="passwordResetUri" class="auth-links text-xs-center opacity-80">
<a :href="passwordResetUri" class="text-link link--text">
<translate>Forgot password?</translate>
</a>
@@ -216,6 +236,7 @@ export default {
.catch((e) => {
if (e.response?.data?.code === 32) {
this.enterCode = true;
this.$nextTick(() => this.$refs.code.focus());
}
this.loading = false;
});

View File

@@ -123,7 +123,8 @@ func CreateOAuthToken(router *gin.RouterGroup) {
if s == nil {
AbortInvalidCredentials(c)
return
} else if s.Username() == "" || s.IsClient() || s.IsRegistered() {
} else if s.Username() == "" || s.IsClient() || !s.IsRegistered() {
event.AuditErr([]string{clientIp, "oauth2", actor, action, authn.ErrInvalidGrantType.Error()})
AbortInvalidCredentials(c)
return
}
@@ -159,7 +160,7 @@ func CreateOAuthToken(router *gin.RouterGroup) {
f.GrantType = authn.GrantSession
}
sess = entity.NewClientAuthentication(f.ClientName, f.Lifetime, f.Scope, f.GrantType, s.User())
sess = entity.NewClientSession(f.ClientName, f.ExpiresIn, f.Scope, f.GrantType, s.User())
// Return the reserved request rate limit tokens after successful authentication.
r.Success()

View File

@@ -22,6 +22,7 @@ func GetSessionResponse(authToken string, sess *entity.Session, conf config.Clie
"session_id": sess.ID,
"expires_in": sess.ExpiresIn(),
"provider": sess.Provider().String(),
"scope": sess.Scope(),
"user": sess.User(),
"data": sess.Data(),
"config": conf,
@@ -37,6 +38,7 @@ func GetSessionResponse(authToken string, sess *entity.Session, conf config.Clie
"token_type": sess.AuthTokenType(),
"expires_in": sess.ExpiresIn(),
"provider": sess.Provider().String(),
"scope": sess.Scope(),
"user": sess.User(),
"data": sess.Data(),
"config": conf,

View File

@@ -11,7 +11,9 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/search"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/sortby"
)
// FindUserSessions finds user sessions and returns them as JSON.
@@ -38,18 +40,21 @@ func FindUserSessions(router *gin.RouterGroup) {
return
}
// Init search request form.
var f form.SearchSessions
// Init search form.
err := c.MustBindWith(&f, binding.Form)
// Abort if invalid.
if err != nil {
AbortBadRequest(c)
return
}
// Filter by user.
// Find applications that belong to the current user and sort them by name.
f.UID = s.UserUID
f.Order = sortby.ClientName
f.Provider = authn.ProviderApplication.String()
f.Method = authn.MethodDefault.String()
// Perform search.
result, err := search.Sessions(f)

View File

@@ -93,17 +93,17 @@ func authAddAction(ctx *cli.Context) error {
}
// Create session and show the authentication secret.
sess, err := entity.AddClientAuthentication(clientName, ctx.Int64("expires"), authScope, authn.GrantCLI, user)
sess, err := entity.AddClientSession(clientName, ctx.Int64("expires"), authScope, authn.GrantCLI, user)
if err != nil {
return fmt.Errorf("failed to create authentication secret: %s", err)
} else {
// Show client authentication credentials.
if sess.UserUID == "" {
fmt.Printf("\nPLEASE WRITE DOWN THE FOLLOWING RANDOMLY GENERATED ACCESS TOKEN, AS YOU WILL NOT BE ABLE TO SEE IT AGAIN:\n")
fmt.Printf("\nPLEASE COPY THE FOLLOWING RANDOMLY GENERATED ACCESS TOKEN AND KEEP IT IN A SAFE PLACE, AS YOU WILL NOT BE ABLE TO SEE IT AGAIN:\n")
fmt.Printf("\n%s\n", report.Credentials("Access Token", sess.AuthToken(), "Authorization Scope", sess.Scope()))
} else {
fmt.Printf("\nPLEASE WRITE DOWN THE FOLLOWING RANDOMLY GENERATED APP PASSWORD, AS YOU WILL NOT BE ABLE TO SEE IT AGAIN:\n")
fmt.Printf("\nPLEASE COPY THE FOLLOWING RANDOMLY GENERATED APP PASSWORD AND KEEP IT IN A SAFE PLACE, AS YOU WILL NOT BE ABLE TO SEE IT AGAIN:\n")
fmt.Printf("\n%s\n", report.Credentials("App Password", sess.AuthToken(), "Authorization Scope", sess.Scope()))
}

View File

@@ -76,14 +76,14 @@ func (Session) TableName() string {
return "auth_sessions"
}
// NewSession creates a new session using the maxAge and timeout in seconds.
func NewSession(lifetime, timeout int64) (m *Session) {
// NewSession creates a new session with the expiration time and timeout specified in seconds.
func NewSession(expiresIn, timeout int64) (m *Session) {
m = &Session{}
m.Regenerate()
if lifetime > 0 {
m.SessExpires = TimeStamp().Unix() + lifetime
if expiresIn > 0 {
m.SessExpires = TimeStamp().Unix() + expiresIn
}
if timeout > 0 {
@@ -93,16 +93,6 @@ func NewSession(lifetime, timeout int64) (m *Session) {
return m
}
// Expires sets an explicit expiration time.
func (m *Session) Expires(t time.Time) *Session {
if t.IsZero() {
return m
}
m.SessExpires = t.Unix()
return m
}
// DeleteExpiredSessions deletes all expired sessions.
func DeleteExpiredSessions() (deleted int) {
found := Sessions{}
@@ -855,6 +845,16 @@ func (m *Session) RedeemToken(token string) (n int) {
}
}
// Expires sets an explicit expiration time.
func (m *Session) Expires(t time.Time) *Session {
if t.IsZero() {
return m
}
m.SessExpires = t.Unix()
return m
}
// ExpiresAt returns the time when the session expires.
func (m *Session) ExpiresAt() time.Time {
if m.SessExpires <= 0 {
@@ -873,6 +873,17 @@ func (m *Session) ExpiresIn() int64 {
return m.SessExpires - unix.Time()
}
// Expired checks if the session has expired.
func (m *Session) Expired() bool {
if m.SessExpires <= 0 {
return m.TimedOut()
} else if at := m.ExpiresAt(); at.IsZero() {
return false
} else {
return at.Before(UTC())
}
}
// TimeoutAt returns the time at which the session will expire due to inactivity.
func (m *Session) TimeoutAt() time.Time {
if m.SessTimeout <= 0 || m.LastActive <= 0 {
@@ -893,17 +904,6 @@ func (m *Session) TimedOut() bool {
}
}
// Expired checks if the session has expired.
func (m *Session) Expired() bool {
if m.SessExpires <= 0 {
return m.TimedOut()
} else if at := m.ExpiresAt(); at.IsZero() {
return false
} else {
return at.Before(UTC())
}
}
// UpdateLastActive sets the last activity of the session to now.
func (m *Session) UpdateLastActive() *Session {
if m.Invalid() {

View File

@@ -5,9 +5,9 @@ import (
"github.com/photoprism/photoprism/pkg/rnd"
)
// NewClientAuthentication returns a new session that authenticates a client application.
func NewClientAuthentication(clientName string, lifetime int64, scope string, grantType authn.GrantType, user *User) *Session {
sess := NewSession(lifetime, 0)
// NewClientSession returns a new session that authenticates a client application.
func NewClientSession(clientName string, expiresIn int64, scope string, grantType authn.GrantType, user *User) *Session {
sess := NewSession(expiresIn, 0)
if clientName == "" {
clientName = rnd.Name()
@@ -30,9 +30,9 @@ func NewClientAuthentication(clientName string, lifetime int64, scope string, gr
return sess
}
// AddClientAuthentication creates a new session for authenticating a client application.
func AddClientAuthentication(clientName string, lifetime int64, scope string, grantType authn.GrantType, user *User) (*Session, error) {
sess := NewClientAuthentication(clientName, lifetime, scope, grantType, user)
// AddClientSession creates a new session for authenticating a client application.
func AddClientSession(clientName string, expiresIn int64, scope string, grantType authn.GrantType, user *User) (*Session, error) {
sess := NewClientSession(clientName, expiresIn, scope, grantType, user)
if err := sess.Create(); err != nil {
return nil, err

View File

@@ -9,9 +9,9 @@ import (
"github.com/photoprism/photoprism/pkg/unix"
)
func TestNewClientAuthentication(t *testing.T) {
func TestNewClientSession(t *testing.T) {
t.Run("Anonymous", func(t *testing.T) {
sess := NewClientAuthentication("Anonymous", unix.Day, "metrics", authn.GrantClientCredentials, nil)
sess := NewClientSession("Anonymous", unix.Day, "metrics", authn.GrantClientCredentials, nil)
if sess == nil {
t.Fatal("session must not be nil")
@@ -26,7 +26,7 @@ func TestNewClientAuthentication(t *testing.T) {
t.Fatal("user must not be nil")
}
sess := NewClientAuthentication("alice", unix.Day, "metrics", authn.GrantPassword, user)
sess := NewClientSession("alice", unix.Day, "metrics", authn.GrantPassword, user)
if sess == nil {
t.Fatal("session must not be nil")
@@ -41,7 +41,7 @@ func TestNewClientAuthentication(t *testing.T) {
t.Fatal("user must not be nil")
}
sess := NewClientAuthentication("alice", unix.Day, "", authn.GrantCLI, user)
sess := NewClientSession("alice", unix.Day, "", authn.GrantCLI, user)
if sess == nil {
t.Fatal("session must not be nil")
@@ -56,7 +56,7 @@ func TestNewClientAuthentication(t *testing.T) {
t.Fatal("user must not be nil")
}
sess := NewClientAuthentication("", 0, "metrics", authn.GrantCLI, user)
sess := NewClientSession("", 0, "metrics", authn.GrantCLI, user)
if sess == nil {
t.Fatal("session must not be nil")
@@ -66,9 +66,9 @@ func TestNewClientAuthentication(t *testing.T) {
})
}
func TestAddClientAuthentication(t *testing.T) {
func TestAddClientSession(t *testing.T) {
t.Run("Anonymous", func(t *testing.T) {
sess, err := AddClientAuthentication("", unix.Day, "metrics", authn.GrantClientCredentials, nil)
sess, err := AddClientSession("", unix.Day, "metrics", authn.GrantClientCredentials, nil)
assert.NoError(t, err)
@@ -85,7 +85,7 @@ func TestAddClientAuthentication(t *testing.T) {
t.Fatal("user must not be nil")
}
sess, err := AddClientAuthentication("My Client App Token", unix.Day, "metrics", authn.GrantCLI, user)
sess, err := AddClientSession("My Client App Token", unix.Day, "metrics", authn.GrantCLI, user)
assert.NoError(t, err)

View File

@@ -21,7 +21,7 @@ type OAuthCreateToken struct {
RedirectURI string `form:"redirect_uri" json:"redirect_uri,omitempty"`
Assertion string `form:"assertion" json:"assertion,omitempty"`
Scope string `form:"scope" json:"scope,omitempty"`
Lifetime int64 `form:"lifetime" json:"lifetime,omitempty"`
ExpiresIn int64 `form:"expires_in" json:"expires_in,omitempty"`
}
// Validate verifies the request parameters depending on the grant type.
@@ -41,7 +41,7 @@ func (f OAuthCreateToken) Validate() error {
} else if !rnd.IsAlnum(f.ClientSecret) {
return authn.ErrInvalidCredentials
}
case authn.GrantPassword:
case authn.GrantPassword, authn.GrantSession:
// Validate request credentials.
if f.Username == "" {
return authn.ErrUsernameRequired

View File

@@ -1,22 +1,39 @@
package form
import "github.com/photoprism/photoprism/pkg/authn"
// SearchSessions represents a session search form.
type SearchSessions struct {
Query string `form:"q"`
UID string `form:"uid"`
Provider string `form:"provider"`
Method string `form:"method"`
Count int `form:"count" binding:"required" serialize:"-"`
Offset int `form:"offset" serialize:"-"`
Order string `form:"order" serialize:"-"`
}
// AuthProviders returns the normalized authentication provider types.
func (f *SearchSessions) AuthProviders() []authn.ProviderType {
return authn.Providers(f.Provider)
}
// AuthMethods returns the normalized authentication method types.
func (f *SearchSessions) AuthMethods() []authn.MethodType {
return authn.Methods(f.Method)
}
// GetQuery returns the query string.
func (f *SearchSessions) GetQuery() string {
return f.Query
}
// SetQuery sets the query string.
func (f *SearchSessions) SetQuery(q string) {
f.Query = q
}
// ParseQueryString parses the query string into form fields.
func (f *SearchSessions) ParseQueryString() error {
return ParseQueryString(f)
}

View File

@@ -1,10 +1,13 @@
package search
import (
"fmt"
"strings"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/sortby"
)
// Sessions finds user sessions.
@@ -15,10 +18,22 @@ func Sessions(f form.SearchSessions) (result entity.Sessions, err error) {
userUid := strings.TrimSpace(f.UID)
search := strings.TrimSpace(f.Query)
sortOrder := f.Order
order := f.Order
limit := f.Count
offset := f.Offset
// Limit maximum number of results.
if limit > MaxResults {
limit = MaxResults
}
// Set default sort order or use normalized order value.
if order == "" {
order = sortby.LastActive
} else {
order = clean.TypeLowerUnderscore(order)
}
// Filter by user UID?
if userUid != "" {
stmt = stmt.Where("user_uid = ?", userUid)
@@ -26,14 +41,34 @@ func Sessions(f form.SearchSessions) (result entity.Sessions, err error) {
// Filter by username and/or auth provider name?
if search != "" && search != "all" {
stmt = stmt.Where("user_name LIKE ? OR auth_provider LIKE ?", search+"%", search+"%")
stmt = stmt.Where("user_name LIKE ? OR client_name LIKE ?", search+"%", search+"%")
}
// Filter by authentication providers?
if f.Provider != "" {
stmt = stmt.Where("auth_provider IN (?)", f.AuthProviders())
}
// Filter by authentication methods?
if f.Method != "" {
stmt = stmt.Where("auth_method IN (?)", f.AuthMethods())
}
// Sort results?
if sortOrder == "" {
sortOrder = "last_active DESC, user_name"
switch order {
case sortby.LastActive:
stmt = stmt.Order("last_active DESC, user_name, client_name, id")
case sortby.SessExpires:
stmt = stmt.Order("sess_expires DESC, user_name, client_name, id")
case sortby.ClientName:
stmt = stmt.Where("client_name <> '' AND client_name IS NOT NULL").Order("client_name, created_at, id")
case sortby.CreatedAt:
stmt = stmt.Order("created_at ASC, user_name, client_name, id")
default:
return result, fmt.Errorf("invalid sort order %s", order)
}
// Apply limit and offset.
if limit > 0 {
stmt = stmt.Limit(limit)
@@ -42,7 +77,8 @@ func Sessions(f form.SearchSessions) (result entity.Sessions, err error) {
}
}
err = stmt.Order(sortOrder).Find(&result).Error
// Perform query.
err = stmt.Find(&result).Error
return result, err
}

View File

@@ -7,9 +7,14 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/sortby"
)
func TestSessions(t *testing.T) {
expectedUserUid := "uqxetse3cy5eo9z2"
expectedUserName := "alice"
expectedSessionId := "fde4d5154d5383370c9f0c21fd51655d54a185a26dc043d1866fc4678e7ecb62"
t.Run("Default", func(t *testing.T) {
if results, err := Sessions(form.SearchSessions{}); err != nil {
t.Fatal(err)
@@ -27,23 +32,62 @@ func TestSessions(t *testing.T) {
}
})
t.Run("Offset", func(t *testing.T) {
if results, err := Sessions(form.SearchSessions{Offset: 1}); err != nil {
if results, err := Sessions(form.SearchSessions{Offset: 1, Order: sortby.LastActive}); err != nil {
t.Fatal(err)
} else {
assert.LessOrEqual(t, 2, len(results))
//t.Logf("sessions: %#v", results)
}
})
t.Run("SearchAlice", func(t *testing.T) {
if results, err := Sessions(form.SearchSessions{Count: 100, Query: "alice", Order: "sess_expires DESC, user_name"}); err != nil {
t.Run("Search", func(t *testing.T) {
if results, err := Sessions(form.SearchSessions{Count: 100, Query: expectedUserName, Order: sortby.SessExpires}); err != nil {
t.Fatal(err)
} else {
t.Logf("sessions: %#v", results)
// t.Logf("sessions: %#v", results)
assert.LessOrEqual(t, 1, len(results))
if len(results) > 0 {
assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"), results[0].ID)
assert.Equal(t, "uqxetse3cy5eo9z2", results[0].UserUID)
assert.Equal(t, "alice", results[0].UserName)
assert.Equal(t, expectedUserUid, results[0].UserUID)
assert.Equal(t, expectedUserName, results[0].UserName)
}
}
})
t.Run("UID", func(t *testing.T) {
if results, err := Sessions(form.SearchSessions{Count: 100, UID: expectedUserUid, Order: sortby.SessExpires}); err != nil {
t.Fatal(err)
} else {
// t.Logf("sessions: %#v", results)
assert.LessOrEqual(t, 1, len(results))
if len(results) > 0 {
assert.Equal(t, rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"), results[0].ID)
assert.Equal(t, expectedUserUid, results[0].UserUID)
assert.Equal(t, expectedUserName, results[0].UserName)
}
}
})
t.Run("Providers", func(t *testing.T) {
if results, err := Sessions(form.SearchSessions{Count: 100, UID: expectedUserUid, Provider: "default,application,client,local,access_token", Order: sortby.ClientName}); err != nil {
t.Fatal(err)
} else {
// t.Logf("sessions: %#v", results)
assert.LessOrEqual(t, 1, len(results))
if len(results) > 0 {
assert.Equal(t, expectedSessionId, results[0].ID)
assert.Equal(t, expectedUserUid, results[0].UserUID)
assert.Equal(t, expectedUserName, results[0].UserName)
}
}
})
t.Run("Methods", func(t *testing.T) {
if results, err := Sessions(form.SearchSessions{Count: 100, UID: expectedUserUid, Method: "default,oauth2,session,2fa", Order: sortby.ClientName}); err != nil {
t.Fatal(err)
} else {
// t.Logf("sessions: %#v", results)
assert.LessOrEqual(t, 1, len(results))
if len(results) > 0 {
assert.Equal(t, expectedSessionId, results[0].ID)
assert.Equal(t, expectedUserUid, results[0].UserUID)
assert.Equal(t, expectedUserName, results[0].UserName)
}
}
})

View File

@@ -38,6 +38,18 @@ func Method(s string) MethodType {
}
}
// Methods casts a string to normalized method type strings.
func Methods(s string) []MethodType {
items := strings.Split(s, ",")
result := make([]MethodType, 0, len(items))
for i := range items {
result = append(result, Method(items[i]))
}
return result
}
// Pretty returns the provider identifier in an easy-to-read format.
func (t MethodType) Pretty() string {
switch t {

View File

@@ -1,6 +1,8 @@
package authn
import (
"strings"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/txt"
@@ -78,6 +80,18 @@ func Provider(s string) ProviderType {
}
}
// Providers casts a string to normalized provider type strings.
func Providers(s string) []ProviderType {
items := strings.Split(s, ",")
result := make([]ProviderType, 0, len(items))
for i := range items {
result = append(result, Provider(items[i]))
}
return result
}
// Pretty returns the provider identifier in an easy-to-read format.
func (t ProviderType) Pretty() string {
switch t {

View File

@@ -22,4 +22,8 @@ const (
Similar = "similar"
Random = "random"
Invalid = "invalid"
LastActive = "last_active"
SessExpires = "sess_expires"
ClientName = "client_name"
CreatedAt = "created_at"
)