mirror of
https://github.com/photoprism/photoprism.git
synced 2025-10-30 03:41:57 +08:00
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
color: #c8e3e7!important;
|
||||
}
|
||||
|
||||
#photoprism main .auth-actions .auth-links {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
/* Auth Form Logo */
|
||||
|
||||
#photoprism .auth-login .logo {
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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: "~",
|
||||
},
|
||||
*/
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,4 +22,8 @@ const (
|
||||
Similar = "similar"
|
||||
Random = "random"
|
||||
Invalid = "invalid"
|
||||
LastActive = "last_active"
|
||||
SessExpires = "sess_expires"
|
||||
ClientName = "client_name"
|
||||
CreatedAt = "created_at"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user