OAuth2: Refactor "client add" and "client mod" CLI commands #808 #3943

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2024-01-29 13:54:50 +01:00
parent daca63f94e
commit 305e7bac68
38 changed files with 714 additions and 301 deletions

View File

@ -11,6 +11,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/report"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
)
// AuthAddFlags specifies the "photoprism auth add" command flags.
@ -26,7 +27,7 @@ var AuthAddFlags = []cli.Flag{
cli.Int64Flag{
Name: "expires, e",
Usage: "authentication `LIFETIME` in seconds, after which access expires (-1 to disable the limit)",
Value: entity.UnixYear,
Value: unix.Year,
},
}

View File

@ -4,22 +4,25 @@ import (
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/unix"
)
// Usage hints for the client management subcommands.
const (
ClientIdUsage = "static client `UID` for test purposes"
ClientSecretUsage = "static client `SECRET` for test purposes"
ClientNameUsage = "`CLIENT` name to help identify the application"
ClientRoleUsage = "client authorization `ROLE`"
ClientAuthScope = "client authorization `SCOPES` e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all)"
ClientAuthProvider = "client authentication `PROVIDER`"
ClientAuthMethod = "client authentication `METHOD`"
ClientAuthExpires = "authentication `LIFETIME` in seconds, after which a new access token must be requested (-1 to disable the limit)"
ClientAuthTokens = "maximum 'NUMBER' of access tokens that the client can request (-1 to disable the limit)"
ClientRegenerateSecret = "generate a new client secret and display it"
ClientAuthExpires = "access token `LIFETIME` in seconds, after which a new token must be requested"
ClientAuthTokens = "maximum `NUMBER` of access tokens that the client can request (-1 to disable the limit)"
ClientRegenerateSecret = "set a new randomly generated client secret"
ClientEnable = "enable client authentication if disabled"
ClientDisable = "disable client authentication"
ClientSecretInfo = "\nPLEASE WRITE DOWN THE %s CLIENT SECRET, AS YOU WILL NOT BE ABLE TO SEE IT AGAIN:\n"
)
// ClientsCommands configures the client application subcommands.
@ -39,6 +42,11 @@ var ClientsCommands = cli.Command{
// ClientAddFlags specifies the "photoprism client add" command flags.
var ClientAddFlags = []cli.Flag{
cli.StringFlag{
Name: "id",
Usage: ClientIdUsage,
Hidden: true,
},
cli.StringFlag{
Name: "name, n",
Usage: ClientNameUsage,
@ -67,13 +75,18 @@ var ClientAddFlags = []cli.Flag{
cli.Int64Flag{
Name: "expires, e",
Usage: ClientAuthExpires,
Value: entity.UnixDay,
Value: unix.Day,
},
cli.Int64Flag{
Name: "tokens, t",
Usage: ClientAuthTokens,
Value: 10,
},
cli.StringFlag{
Name: "secret",
Usage: ClientSecretUsage,
Hidden: true,
},
}
// ClientModFlags specifies the "photoprism client mod" command flags.
@ -106,13 +119,20 @@ var ClientModFlags = []cli.Flag{
cli.Int64Flag{
Name: "expires, e",
Usage: ClientAuthExpires,
Value: unix.Day,
},
cli.Int64Flag{
Name: "tokens, t",
Usage: ClientAuthTokens,
Value: 10,
},
cli.StringFlag{
Name: "secret",
Usage: ClientSecretUsage,
Hidden: true,
},
cli.BoolFlag{
Name: "regenerate-secret, r",
Name: "regenerate",
Usage: ClientRegenerateSecret,
},
cli.BoolFlag{

View File

@ -2,7 +2,6 @@ package commands
import (
"fmt"
"time"
"github.com/manifoldco/promptui"
"github.com/urfave/cli"
@ -20,7 +19,7 @@ import (
var ClientsAddCommand = cli.Command{
Name: "add",
Usage: "Registers a new client application",
Description: "Specifying a username as argument will assign the client application to a registered user account.",
Description: "If you specify a username as argument, the new client will belong to this user and inherit its privileges.",
ArgsUsage: "[username]",
Flags: ClientAddFlags,
Action: clientsAddAction,
@ -31,7 +30,7 @@ func clientsAddAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
conf.MigrateDb(false, nil)
frm := form.NewClientFromCli(ctx)
frm := form.AddClientFromCli(ctx)
interactive := true
@ -55,11 +54,6 @@ func clientsAddAction(ctx *cli.Context) error {
frm.ClientName = clean.Name(res)
}
// Set a default client name if no specific name has been provided.
if frm.ClientName == "" {
frm.ClientName = time.Now().UTC().Format(time.DateTime)
}
if interactive && frm.AuthScope == "" {
prompt := promptui.Prompt{
Label: "Authorization Scope",
@ -82,23 +76,26 @@ func clientsAddAction(ctx *cli.Context) error {
client, addErr := entity.AddClient(frm)
if addErr != nil {
return fmt.Errorf("failed to add client: %s", addErr)
return addErr
} else {
log.Infof("successfully registered new client %s", clean.LogQuote(client.ClientName))
// Display client details.
cols := []string{"Client ID", "Client Name", "Authentication Method", "User", "Role", "Scope", "Enabled", "Authentication Expires", "Created At"}
cols := []string{"Client ID", "Client Name", "Authentication Method", "User", "Role", "Scope", "Enabled", "Access Token Lifetime", "Created At"}
rows := make([][]string, 1)
var authExpires string
if client.AuthExpires > 0 {
authExpires = client.Expires().String()
} else {
authExpires = report.Never
}
if client.AuthTokens > 0 {
authExpires = fmt.Sprintf("%s; up to %d tokens", authExpires, client.AuthTokens)
if authExpires != "" {
authExpires = fmt.Sprintf("%s; up to %d tokens", authExpires, client.AuthTokens)
} else {
authExpires = fmt.Sprintf("up to %d tokens", client.AuthTokens)
}
}
rows[0] = []string{
@ -118,16 +115,28 @@ func clientsAddAction(ctx *cli.Context) error {
}
}
if secret, err := client.NewSecret(); err != nil {
// Failed to create client secret.
return fmt.Errorf("failed to create client secret: %s", err)
// Se a random secret or the secret specified in the command flags, if any.
var secret, message string
var err error
if secret = frm.Secret(); secret == "" {
secret, err = client.NewSecret()
message = fmt.Sprintf(ClientSecretInfo, "FOLLOWING RANDOMLY GENERATED")
} else {
// Show client authentication credentials.
fmt.Printf("\nPLEASE WRITE DOWN THE FOLLOWING RANDOMLY GENERATED CLIENT SECRET, AS YOU WILL NOT BE ABLE TO SEE IT AGAIN:\n")
result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret)
fmt.Printf("\n%s\n", result)
err = client.SetSecret(secret)
message = fmt.Sprintf(ClientSecretInfo, "SPECIFIED")
}
// Check if the secret has been saved successfully or return an error otherwise.
if err != nil {
return fmt.Errorf("failed to set client secret: %s", err)
}
// Show client authentication credentials.
fmt.Printf(message)
result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret)
fmt.Printf("\n%s\n", result)
return nil
})
}

View File

@ -23,7 +23,7 @@ var ClientsListCommand = cli.Command{
// clientsListAction lists registered client applications
func clientsListAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
cols := []string{"Client ID", "Client Name", "Authentication Method", "User", "Role", "Scope", "Enabled", "Authentication Expires", "Created At"}
cols := []string{"Client ID", "Client Name", "Authentication Method", "User", "Role", "Scope", "Enabled", "Access Token Lifetime", "Created At"}
// Fetch clients from database.
clients, err := query.Clients(ctx.Int("n"), 0, "", ctx.Args().First())
@ -45,14 +45,17 @@ func clientsListAction(ctx *cli.Context) error {
// Display report.
for i, client := range clients {
var authExpires string
if client.AuthExpires > 0 {
authExpires = client.Expires().String()
} else {
authExpires = report.Never
}
if client.AuthTokens > 0 {
authExpires = fmt.Sprintf("%s; up to %d tokens", authExpires, client.AuthTokens)
if authExpires != "" {
authExpires = fmt.Sprintf("%s; up to %d tokens", authExpires, client.AuthTokens)
} else {
authExpires = fmt.Sprintf("up to %d tokens", client.AuthTokens)
}
}
rows[i] = []string{

View File

@ -62,7 +62,7 @@ func TestClientsListCommand(t *testing.T) {
// Check command output for plausibility.
// t.Logf(output)
assert.NoError(t, err)
assert.Contains(t, output, "Client ID;Client Name;Authentication Method;User;Role;Scope;Enabled;Authentication Expires;Created At")
assert.Contains(t, output, "Client ID;Client Name;Authentication Method;User;Role;Scope;Enabled;Access Token Lifetime;Created At")
assert.Contains(t, output, "Monitoring")
assert.Contains(t, output, "metrics")
assert.NotContains(t, output, "bob")

View File

@ -7,7 +7,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/report"
)
@ -26,10 +26,10 @@ func clientsModAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
conf.MigrateDb(false, nil)
id := clean.UID(ctx.Args().First())
frm := form.ModClientFromCli(ctx)
// Name or UID provided?
if id == "" {
if frm.ID() == "" {
log.Infof("no valid client id specified")
return cli.ShowSubcommandHelp(ctx)
}
@ -37,73 +37,58 @@ func clientsModAction(ctx *cli.Context) error {
// Find client record.
var client *entity.Client
client = entity.FindClientByUID(id)
client = entity.FindClientByUID(frm.ID())
if client == nil {
return fmt.Errorf("client %s not found", clean.LogQuote(id))
return fmt.Errorf("client %s not found", clean.LogQuote(frm.ID()))
}
if name := clean.Name(ctx.String("name")); name != "" {
client.ClientName = name
}
// Update client from form values.
client.SetFormValues(frm)
if ctx.IsSet("method") {
client.AuthMethod = authn.Method(ctx.String("method")).String()
}
if ctx.IsSet("scope") {
client.SetScope(ctx.String("scope"))
}
if expires := ctx.Int64("expires"); expires != 0 {
if expires > entity.UnixMonth {
client.AuthExpires = entity.UnixMonth
} else if expires > 0 {
client.AuthExpires = expires
} else if expires <= 0 {
client.AuthExpires = entity.UnixHour
}
}
if tokens := ctx.Int64("tokens"); tokens != 0 {
if tokens > 2147483647 {
client.AuthTokens = 2147483647
} else if tokens > 0 {
client.AuthTokens = tokens
} else if tokens < 0 {
client.AuthTokens = -1
}
}
if ctx.IsSet("disable") && ctx.Bool("disable") {
client.AuthEnabled = false
if ctx.IsSet("enable") || ctx.IsSet("disable") {
client.AuthEnabled = frm.AuthEnabled
log.Infof("disabled client authentication")
} else if ctx.IsSet("enable") && ctx.Bool("enable") {
client.AuthEnabled = true
log.Warnf("enabled client authentication")
}
// Save changes.
if client.AuthEnabled {
log.Infof("client authentication is enabled")
} else {
log.Warnf("client authentication is disabled")
}
// Update client record if valid.
if err := client.Validate(); err != nil {
return fmt.Errorf("invalid client settings: %s", err)
return fmt.Errorf("invalid values: %s", err)
} else if err = client.Save(); err != nil {
return fmt.Errorf("failed to update client settings: %s", err)
return err
} else {
log.Infof("client %s has been updated", clean.LogQuote(client.ClientName))
}
// Regenerate and display secret, if requested.
if ctx.IsSet("regenerate-secret") && ctx.Bool("regenerate-secret") {
if secret, err := client.NewSecret(); err != nil {
// Failed to create client secret.
return fmt.Errorf("failed to create client secret: %s", err)
} else {
// Show client authentication credentials.
fmt.Printf("\nTHE FOLLOWING RANDOMLY GENERATED CLIENT ID AND SECRET ARE REQUIRED FOR AUTHENTICATION:\n")
result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret)
fmt.Printf("\n%s", result)
fmt.Printf("\nPLEASE WRITE THE CREDENTIALS DOWN AND KEEP THEM IN A SAFE PLACE, AS THE SECRET CANNOT BE DISPLAYED AGAIN.\n\n")
// Change client secret if requested.
var secret, message string
var err error
if ctx.IsSet("regenerate") && ctx.Bool("regenerate") {
if secret, err = client.NewSecret(); err != nil {
return fmt.Errorf("failed to regenerate client secret: %s", err)
}
message = fmt.Sprintf(ClientSecretInfo, "FOLLOWING RANDOMLY GENERATED")
} else if secret = frm.Secret(); secret == "" {
log.Debugf("client secret remains unchanged")
} else if err = client.SetSecret(secret); err != nil {
return fmt.Errorf("failed to set client secret: %s", err)
} else {
message = fmt.Sprintf(ClientSecretInfo, "NEW")
}
// Show new client secret.
if secret != "" && err == nil {
fmt.Printf(message)
result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret)
fmt.Printf("\n%s\n", result)
}
return nil

View File

@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
)
// ClientUID is the unique ID prefix.
@ -64,7 +65,7 @@ func NewClient() *Client {
AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "",
AuthExpires: UnixHour,
AuthExpires: unix.Hour,
AuthTokens: 5,
AuthEnabled: true,
LastActive: 0,
@ -113,9 +114,20 @@ func (m *Client) Name() string {
return m.ClientName
}
// SetName sets a custom client name.
func (m *Client) SetName(s string) *Client {
if s = clean.Name(s); s != "" {
m.ClientName = s
}
return m
}
// SetRole sets the client role specified as string.
func (m *Client) SetRole(role string) *Client {
m.ClientRole = acl.ClientRoles[clean.Role(role)].String()
if role != "" {
m.ClientRole = acl.ClientRoles[clean.Role(role)].String()
}
return m
}
@ -173,7 +185,7 @@ func (m *Client) UserInfo() string {
if m == nil {
return ""
} else if m.UserUID == "" {
return ""
return "n/a"
} else if m.UserName != "" {
return m.UserName
}
@ -242,21 +254,36 @@ func (m *Client) Updates(values interface{}) error {
return UnscopedDb().Model(m).Updates(values).Error
}
// NewSecret sets a new secret stored as hash.
func (m *Client) NewSecret() (s string, err error) {
// NewSecret sets a random client secret and returns it if successful.
func (m *Client) NewSecret() (secret string, err error) {
if !m.HasUID() {
return "", fmt.Errorf("invalid client uid")
}
s = rnd.Base62(32)
secret = rnd.ClientSecret()
pw := NewPassword(m.ClientUID, s, false)
if err = pw.Save(); err != nil {
if err = m.SetSecret(secret); err != nil {
return "", err
}
return s, nil
return secret, nil
}
// SetSecret updates the current client secret or returns an error otherwise.
func (m *Client) SetSecret(secret string) (err error) {
if !m.HasUID() {
return fmt.Errorf("invalid client uid")
} else if !rnd.IsClientSecret(secret) {
return fmt.Errorf("invalid client secret")
}
pw := NewPassword(m.ClientUID, secret, false)
if err = pw.Save(); err != nil {
return err
}
return nil
}
// HasSecret checks if the given client secret is correct.
@ -296,19 +323,37 @@ func (m *Client) Provider() authn.ProviderType {
return authn.Provider(m.AuthProvider)
}
// SetProvider sets a custom client authentication provider.
func (m *Client) SetProvider(provider authn.ProviderType) *Client {
if !provider.IsDefault() {
m.AuthProvider = provider.String()
}
return m
}
// Method returns the client authentication method.
func (m *Client) Method() authn.MethodType {
return authn.Method(m.AuthMethod)
}
// SetMethod sets a custom client authentication method.
func (m *Client) SetMethod(method authn.MethodType) *Client {
if !method.IsDefault() {
m.AuthMethod = method.String()
}
return m
}
// Scope returns the client authorization scope.
func (m *Client) Scope() string {
return clean.Scope(m.AuthScope)
}
// SetScope sets the client authorization scope.
// SetScope sets a custom client authorization scope.
func (m *Client) SetScope(s string) *Client {
m.AuthScope = clean.Scope(s)
if s = clean.Scope(s); s != "" {
m.AuthScope = clean.Scope(s)
}
return m
}
@ -318,7 +363,7 @@ func (m *Client) UpdateLastActive() *Client {
return m
}
m.LastActive = UnixTime()
m.LastActive = unix.Time()
if err := Db().Model(m).UpdateColumn("LastActive", m.LastActive).Error; err != nil {
log.Debugf("client: failed to update %s timestamp (%s)", m.ClientUID, err)
@ -351,6 +396,29 @@ func (m *Client) Expires() time.Duration {
return time.Duration(m.AuthExpires) * time.Second
}
// SetExpires sets a custom auth expiration time in seconds.
func (m *Client) SetExpires(i int64) *Client {
if i != 0 {
m.AuthExpires = i
}
return m
}
// Tokens returns maximum number of access tokens this client can create.
func (m *Client) Tokens() time.Duration {
return time.Duration(m.AuthExpires) * time.Second
}
// SetTokens sets a custom access token limit for this client.
func (m *Client) SetTokens(i int64) *Client {
if i != 0 {
m.AuthTokens = i
}
return m
}
// Report returns the entity values as rows.
func (m *Client) Report(skipEmpty bool) (rows [][]string, cols []string) {
cols = []string{"Name", "Value"}
@ -380,51 +448,50 @@ func (m *Client) Report(skipEmpty bool) (rows [][]string, cols []string) {
// SetFormValues sets the values specified in the form.
func (m *Client) SetFormValues(frm form.Client) *Client {
if frm.UserUID == "" && frm.UserName == "" {
// Ignore.
// Client does not belong to a specific user or the user remains unchanged.
} else if u := FindUser(User{UserUID: frm.UserUID, UserName: frm.UserName}); u != nil {
m.SetUser(u)
}
if frm.ClientName != "" {
m.ClientName = frm.Name()
// Set custom client UID?
if id := frm.ID(); m.ClientUID == "" && id != "" {
m.ClientUID = id
}
if frm.ClientRole != "" {
m.SetRole(frm.ClientRole)
}
if frm.AuthProvider != "" {
m.AuthProvider = frm.Provider().String()
}
if frm.AuthMethod != "" {
m.AuthMethod = frm.Method().String()
}
if frm.AuthScope != "" {
m.SetScope(frm.AuthScope)
}
if frm.AuthExpires > UnixMonth {
m.AuthExpires = UnixMonth
} else if frm.AuthExpires > 0 {
m.AuthExpires = frm.AuthExpires
} else if m.AuthExpires <= 0 {
m.AuthExpires = UnixHour
}
if frm.AuthTokens > 2147483647 {
m.AuthTokens = 2147483647
} else if frm.AuthTokens > 0 {
m.AuthTokens = frm.AuthTokens
} else if m.AuthTokens < 0 {
m.AuthTokens = -1
}
// Set values from form.
m.SetName(frm.Name())
m.SetProvider(frm.Provider())
m.SetMethod(frm.Method())
m.SetScope(frm.Scope())
m.SetTokens(frm.Tokens())
m.SetExpires(frm.Expires())
// Enable authentication?
if frm.AuthEnabled {
m.AuthEnabled = true
}
// Replace empty values with defaults.
if m.AuthProvider == "" {
m.AuthProvider = authn.ProviderClientCredentials.String()
}
if m.AuthMethod == "" {
m.AuthMethod = authn.MethodOAuth2.String()
}
if m.AuthScope == "" {
m.AuthScope = "*"
}
if m.AuthExpires <= 0 {
m.AuthExpires = unix.Hour
}
if m.AuthTokens <= 0 {
m.AuthTokens = -1
}
return m
}

View File

@ -1,11 +1,17 @@
package entity
import (
"fmt"
"github.com/photoprism/photoprism/internal/form"
)
// AddClient creates a new client and returns it if successful.
func AddClient(frm form.Client) (client *Client, err error) {
if found := FindClientByUID(frm.ID()); found != nil {
return found, fmt.Errorf("client id %s already exists", found.ClientUID)
}
client = NewClient().SetFormValues(frm)
if err = client.Validate(); err != nil {

View File

@ -3,6 +3,7 @@ package entity
import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/unix"
)
type ClientMap map[string]Client
@ -37,7 +38,7 @@ var ClientFixtures = ClientMap{
AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "*",
AuthExpires: UnixDay,
AuthExpires: unix.Day,
AuthTokens: -1,
AuthEnabled: true,
LastActive: 0,
@ -73,7 +74,7 @@ var ClientFixtures = ClientMap{
AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "metrics",
AuthExpires: UnixHour,
AuthExpires: unix.Hour,
AuthTokens: 2,
AuthEnabled: true,
LastActive: 0,
@ -91,7 +92,7 @@ var ClientFixtures = ClientMap{
AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodUnknown.String(),
AuthScope: "*",
AuthExpires: UnixHour,
AuthExpires: unix.Hour,
AuthTokens: 2,
AuthEnabled: true,
LastActive: 0,
@ -109,7 +110,7 @@ var ClientFixtures = ClientMap{
AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "metrics",
AuthExpires: UnixHour,
AuthExpires: unix.Hour,
AuthTokens: 2,
AuthEnabled: true,
LastActive: 0,
@ -128,7 +129,7 @@ var ClientFixtures = ClientMap{
AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "statistics",
AuthExpires: UnixHour,
AuthExpires: unix.Hour,
AuthTokens: 2,
AuthEnabled: true,
LastActive: 0,

View File

@ -380,13 +380,13 @@ func TestClient_Expires(t *testing.T) {
func TestClient_UserInfo(t *testing.T) {
t.Run("New", func(t *testing.T) {
assert.Equal(t, "", NewClient().UserInfo())
assert.Equal(t, "n/a", NewClient().UserInfo())
})
t.Run("Alice", func(t *testing.T) {
assert.Equal(t, "alice", ClientFixtures.Pointer("alice").UserInfo())
})
t.Run("Metrics", func(t *testing.T) {
assert.Equal(t, "", ClientFixtures.Pointer("metrics").UserInfo())
assert.Equal(t, "n/a", ClientFixtures.Pointer("metrics").UserInfo())
})
}

View File

@ -19,6 +19,7 @@ import (
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/photoprism/photoprism/pkg/unix"
)
// SessionPrefix for RefID.
@ -101,7 +102,7 @@ func (m *Session) Expires(t time.Time) *Session {
func DeleteExpiredSessions() (deleted int) {
found := Sessions{}
if err := Db().Where("sess_expires > 0 AND sess_expires < ?", UnixTime()).Find(&found).Error; err != nil {
if err := Db().Where("sess_expires > 0 AND sess_expires < ?", unix.Time()).Find(&found).Error; err != nil {
event.AuditErr([]string{"failed to fetch expired sessions", "%s"}, err)
return deleted
}
@ -782,7 +783,7 @@ func (m *Session) ExpiresIn() int64 {
return 0
}
return m.SessExpires - UnixTime()
return m.SessExpires - unix.Time()
}
// TimeoutAt returns the time at which the session will expire due to inactivity.
@ -822,7 +823,7 @@ func (m *Session) UpdateLastActive() *Session {
return m
}
m.LastActive = UnixTime()
m.LastActive = unix.Time()
if err := Db().Model(m).UpdateColumn("LastActive", m.LastActive).Error; err != nil {
event.AuditWarn([]string{m.IP(), "session %s", "failed to update last active time", "%s"}, m.RefID, err)

View File

@ -9,6 +9,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
)
// Create a new session cache with an expiration time of 15 minutes.
@ -31,7 +32,7 @@ func FindSession(id string) (*Session, error) {
// Find the session in the cache with a fallback to the database.
if cacheData, ok := sessionCache.Get(id); ok && cacheData != nil {
if cached := cacheData.(*Session); !cached.Expired() {
cached.LastActive = UnixTime()
cached.LastActive = unix.Time()
return cached, nil
} else if err := cached.Delete(); err != nil {
event.AuditErr([]string{cached.IP(), "session %s", "failed to delete after expiration", "%s"}, cached.RefID, err)

View File

@ -4,11 +4,13 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/unix"
)
func TestNewClientAuthentication(t *testing.T) {
t.Run("Anonymous", func(t *testing.T) {
sess := NewClientAuthentication("Anonymous", UnixDay, "metrics", nil)
sess := NewClientAuthentication("Anonymous", unix.Day, "metrics", nil)
if sess == nil {
t.Fatal("session must not be nil")
@ -23,7 +25,7 @@ func TestNewClientAuthentication(t *testing.T) {
t.Fatal("user must not be nil")
}
sess := NewClientAuthentication("alice", UnixDay, "metrics", user)
sess := NewClientAuthentication("alice", unix.Day, "metrics", user)
if sess == nil {
t.Fatal("session must not be nil")
@ -38,7 +40,7 @@ func TestNewClientAuthentication(t *testing.T) {
t.Fatal("user must not be nil")
}
sess := NewClientAuthentication("alice", UnixDay, "", user)
sess := NewClientAuthentication("alice", unix.Day, "", user)
if sess == nil {
t.Fatal("session must not be nil")
@ -65,7 +67,7 @@ func TestNewClientAuthentication(t *testing.T) {
func TestAddClientAuthentication(t *testing.T) {
t.Run("Anonymous", func(t *testing.T) {
sess, err := AddClientAuthentication("", UnixDay, "metrics", nil)
sess, err := AddClientAuthentication("", unix.Day, "metrics", nil)
assert.NoError(t, err)
@ -82,7 +84,7 @@ func TestAddClientAuthentication(t *testing.T) {
t.Fatal("user must not be nil")
}
sess, err := AddClientAuthentication("My Client App Token", UnixDay, "metrics", user)
sess, err := AddClientAuthentication("My Client App Token", unix.Day, "metrics", user)
assert.NoError(t, err)

View File

@ -4,6 +4,7 @@ import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
)
type SessionMap map[string]Session
@ -29,8 +30,8 @@ var SessionFixtures = SessionMap{
authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"),
RefID: "sessxkkcabcd",
SessTimeout: UnixDay * 3,
SessExpires: UnixTime() + UnixWeek,
SessTimeout: unix.Day * 3,
SessExpires: unix.Time() + unix.Week,
user: UserFixtures.Pointer("alice"),
UserUID: UserFixtures.Pointer("alice").UserUID,
UserName: UserFixtures.Pointer("alice").UserName,
@ -40,7 +41,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("bb8658e779403ae524a188712470060f050054324a8b104e"),
RefID: "sess34q3hael",
SessTimeout: -1,
SessExpires: UnixTime() + UnixDay,
SessExpires: unix.Time() + unix.Day,
AuthScope: clean.Scope("*"),
AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodDefault.String(),
@ -55,7 +56,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("DIbS8T-uyGMe1-R3fmTv-vVaR35"),
RefID: "sess6ey1ykya",
SessTimeout: -1,
SessExpires: UnixTime() + UnixDay,
SessExpires: unix.Time() + unix.Day,
AuthScope: clean.Scope("*"),
AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodPersonal.String(),
@ -70,7 +71,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("5d0rGx-EvsDnV-DcKtYY-HT1aWL"),
RefID: "sesshjtgx8qt",
SessTimeout: -1,
SessExpires: UnixTime() + UnixDay,
SessExpires: unix.Time() + unix.Day,
AuthScope: clean.Scope("webdav"),
AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodPersonal.String(),
@ -85,7 +86,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("778f0f7d80579a072836c65b786145d6e0127505194cc51e"),
RefID: "sessjr0ge18d",
SessTimeout: 0,
SessExpires: UnixTime() + UnixDay,
SessExpires: unix.Time() + unix.Day,
AuthScope: clean.Scope("metrics photos albums videos"),
AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodDefault.String(),
@ -100,8 +101,8 @@ var SessionFixtures = SessionMap{
authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"),
RefID: "sessxkkcabce",
SessTimeout: UnixDay * 3,
SessExpires: UnixTime() + UnixWeek,
SessTimeout: unix.Day * 3,
SessExpires: unix.Time() + unix.Week,
user: UserFixtures.Pointer("bob"),
UserUID: UserFixtures.Pointer("bob").UserUID,
UserName: UserFixtures.Pointer("bob").UserName,
@ -110,8 +111,8 @@ var SessionFixtures = SessionMap{
authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2"),
RefID: "sessxkkcabcf",
SessTimeout: UnixDay * 3,
SessExpires: UnixTime() + UnixWeek,
SessTimeout: unix.Day * 3,
SessExpires: unix.Time() + unix.Week,
user: UserFixtures.Pointer("unauthorized"),
UserUID: UserFixtures.Pointer("unauthorized").UserUID,
UserName: UserFixtures.Pointer("unauthorized").UserName,
@ -120,8 +121,8 @@ var SessionFixtures = SessionMap{
authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"),
RefID: "sessxkkcabcg",
SessTimeout: UnixDay * 3,
SessExpires: UnixTime() + UnixWeek,
SessTimeout: unix.Day * 3,
SessExpires: unix.Time() + unix.Week,
user: &Visitor,
UserUID: Visitor.UserUID,
UserName: Visitor.UserName,
@ -136,7 +137,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("4ebe1048a7384e1e6af2930b5b6f29795ffab691df47a488"),
RefID: "sessaae5cxun",
SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek,
SessExpires: unix.Time() + unix.Week,
AuthScope: clean.Scope("metrics"),
AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodDefault.String(),
@ -149,8 +150,8 @@ var SessionFixtures = SessionMap{
authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4"),
RefID: "sessxkkcabch",
SessTimeout: UnixDay * 3,
SessExpires: UnixTime() + UnixWeek,
SessTimeout: unix.Day * 3,
SessExpires: unix.Time() + unix.Week,
user: UserFixtures.Pointer("friend"),
UserUID: UserFixtures.Pointer("friend").UserUID,
UserName: UserFixtures.Pointer("friend").UserName,
@ -160,7 +161,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368212345"),
RefID: "sessgh612345",
SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek,
SessExpires: unix.Time() + unix.Week,
AuthScope: clean.Scope("metrics"),
AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(),
@ -177,7 +178,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368208a9b"),
RefID: "sessgh6gjuo1",
SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek,
SessExpires: unix.Time() + unix.Week,
AuthScope: clean.Scope("metrics"),
AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodDefault.String(),
@ -193,7 +194,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("3f9684f7d3dd3d5b84edd43289c7fb5ca32ee73bd0233237"),
RefID: "sessyugn54so",
SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek,
SessExpires: unix.Time() + unix.Week,
AuthScope: clean.Scope("settings"),
AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodDefault.String(),
@ -209,7 +210,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368212123"),
RefID: "sessgh6123yt",
SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek,
SessExpires: unix.Time() + unix.Week,
AuthScope: clean.Scope("statistics"),
AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(),

View File

@ -12,6 +12,7 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
)
func TestAuthSession(t *testing.T) {
@ -275,7 +276,7 @@ func TestSessionLogIn(t *testing.T) {
rec := httptest.NewRecorder()
t.Run("Admin", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6)
m := NewSession(unix.Day, unix.Hour*6)
m.SetClientIP(clientIp)
// Create login form.
@ -295,7 +296,7 @@ func TestSessionLogIn(t *testing.T) {
}
})
t.Run("WrongPassword", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6)
m := NewSession(unix.Day, unix.Hour*6)
m.SetClientIP(clientIp)
// Create login form.
@ -315,7 +316,7 @@ func TestSessionLogIn(t *testing.T) {
}
})
t.Run("InvalidUser", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6)
m := NewSession(unix.Day, unix.Hour*6)
m.SetClientIP(clientIp)
// Create login form.
@ -335,7 +336,7 @@ func TestSessionLogIn(t *testing.T) {
}
})
t.Run("Unknown user with token", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6)
m := NewSession(unix.Day, unix.Hour*6)
m.SetClientIP(clientIp)
// Create login form.
@ -355,7 +356,7 @@ func TestSessionLogIn(t *testing.T) {
})
t.Run("Unknown user with invalid token", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6)
m := NewSession(unix.Day, unix.Hour*6)
m.SetClientIP(clientIp)
// Create login form.

View File

@ -11,11 +11,12 @@ import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/header"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
)
func TestNewSession(t *testing.T) {
t.Run("NoSessionData", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6)
m := NewSession(unix.Day, unix.Hour*6)
assert.True(t, rnd.IsAuthToken(m.AuthToken()))
assert.True(t, rnd.IsSessionID(m.ID))
@ -27,7 +28,7 @@ func TestNewSession(t *testing.T) {
assert.Equal(t, 0, len(m.Data().Tokens))
})
t.Run("EmptySessionData", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6)
m := NewSession(unix.Day, unix.Hour*6)
m.SetData(NewSessionData())
assert.True(t, rnd.IsAuthToken(m.AuthToken()))
@ -42,7 +43,7 @@ func TestNewSession(t *testing.T) {
t.Run("WithSessionData", func(t *testing.T) {
data := NewSessionData()
data.Tokens = []string{"foo", "bar"}
m := NewSession(UnixDay, UnixHour*6)
m := NewSession(unix.Day, unix.Hour*6)
m.SetData(data)
assert.True(t, rnd.IsAuthToken(m.AuthToken()))
@ -60,7 +61,7 @@ func TestNewSession(t *testing.T) {
func TestSession_SetData(t *testing.T) {
t.Run("Nil", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6)
m := NewSession(unix.Day, unix.Hour*6)
assert.NotNil(t, m)
@ -74,7 +75,7 @@ func TestSession_SetData(t *testing.T) {
func TestSession_Expires(t *testing.T) {
t.Run("Set expiry date", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour)
m := NewSession(unix.Day, unix.Hour)
initialExpiryDate := m.SessExpires
m.Expires(time.Date(2035, 01, 15, 12, 30, 0, 0, time.UTC))
finalExpiryDate := m.SessExpires
@ -82,7 +83,7 @@ func TestSession_Expires(t *testing.T) {
})
t.Run("Try to set zero date", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour)
m := NewSession(unix.Day, unix.Hour)
initialExpiryDate := m.SessExpires
m.Expires(time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC))
finalExpiryDate := m.SessExpires
@ -92,7 +93,7 @@ func TestSession_Expires(t *testing.T) {
func TestDeleteExpiredSessions(t *testing.T) {
assert.Equal(t, 0, DeleteExpiredSessions())
m := NewSession(UnixDay, UnixHour)
m := NewSession(unix.Day, unix.Hour)
m.Expires(time.Date(2000, 01, 15, 12, 30, 0, 0, time.UTC))
m.Save()
assert.Equal(t, 1, DeleteExpiredSessions())
@ -161,7 +162,7 @@ func TestFindSessionByRefID(t *testing.T) {
func TestSession_Regenerate(t *testing.T) {
t.Run("NewSession", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour)
m := NewSession(unix.Day, unix.Hour)
initialID := m.ID
m.Regenerate()
finalID := m.ID
@ -226,8 +227,8 @@ func TestSession_Create(t *testing.T) {
assert.Empty(t, m)
s := &Session{
UserName: "charles",
SessExpires: UnixDay * 3,
SessTimeout: UnixTime() + UnixWeek,
SessExpires: unix.Day * 3,
SessTimeout: unix.Time() + unix.Week,
RefID: "sessxkkcxxxx",
}
@ -252,8 +253,8 @@ func TestSession_Create(t *testing.T) {
s := &Session{
UserName: "charles",
SessExpires: UnixDay * 3,
SessTimeout: UnixTime() + UnixWeek,
SessExpires: unix.Day * 3,
SessTimeout: unix.Time() + unix.Week,
RefID: "123",
}
@ -274,8 +275,8 @@ func TestSession_Create(t *testing.T) {
s := &Session{
UserName: "charles",
SessExpires: UnixDay * 3,
SessTimeout: UnixTime() + UnixWeek,
SessExpires: unix.Day * 3,
SessTimeout: unix.Time() + unix.Week,
RefID: "sessxkkcxxxx",
}
@ -292,8 +293,8 @@ func TestSession_Save(t *testing.T) {
assert.Empty(t, m)
s := &Session{
UserName: "chris",
SessExpires: UnixDay * 3,
SessTimeout: UnixTime() + UnixWeek,
SessExpires: unix.Day * 3,
SessTimeout: unix.Time() + unix.Week,
RefID: "sessxkkcxxxy",
}
@ -743,14 +744,14 @@ func TestSession_RedeemToken(t *testing.T) {
func TestSession_TimedOut(t *testing.T) {
t.Run("NewSession", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour)
m := NewSession(unix.Day, unix.Hour)
assert.False(t, m.TimeoutAt().IsZero())
assert.Equal(t, m.ExpiresAt(), m.TimeoutAt())
assert.False(t, m.TimedOut())
assert.Greater(t, m.ExpiresIn(), int64(0))
})
t.Run("NoExpiration", func(t *testing.T) {
m := NewSession(0, UnixHour)
m := NewSession(0, unix.Hour)
t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt())
assert.True(t, m.TimeoutAt().IsZero())
assert.Equal(t, m.ExpiresAt(), m.TimeoutAt())
@ -759,7 +760,7 @@ func TestSession_TimedOut(t *testing.T) {
assert.Equal(t, m.ExpiresIn(), int64(0))
})
t.Run("NoTimeout", func(t *testing.T) {
m := NewSession(UnixDay, 0)
m := NewSession(unix.Day, 0)
t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt())
assert.False(t, m.TimeoutAt().IsZero())
assert.Equal(t, m.ExpiresAt(), m.TimeoutAt())
@ -768,19 +769,19 @@ func TestSession_TimedOut(t *testing.T) {
assert.Greater(t, m.ExpiresIn(), int64(0))
})
t.Run("TimedOut", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour)
utc := UnixTime()
m := NewSession(unix.Day, unix.Hour)
utc := unix.Time()
m.LastActive = utc - (UnixHour + 1)
m.LastActive = utc - (unix.Hour + 1)
assert.False(t, m.TimeoutAt().IsZero())
assert.True(t, m.TimedOut())
})
t.Run("NotTimedOut", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour)
utc := UnixTime()
m := NewSession(unix.Day, unix.Hour)
utc := unix.Time()
m.LastActive = utc - (UnixHour - 10)
m.LastActive = utc - (unix.Hour - 10)
assert.False(t, m.TimeoutAt().IsZero())
assert.False(t, m.TimedOut())
@ -789,7 +790,7 @@ func TestSession_TimedOut(t *testing.T) {
func TestSession_Expired(t *testing.T) {
t.Run("NewSession", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour)
m := NewSession(unix.Day, unix.Hour)
t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt())
assert.False(t, m.ExpiresAt().IsZero())
assert.False(t, m.Expired())
@ -813,9 +814,9 @@ func TestSession_Expired(t *testing.T) {
assert.False(t, m.TimedOut())
})
t.Run("Expired", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour)
m := NewSession(unix.Day, unix.Hour)
t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt())
utc := UnixTime()
utc := unix.Time()
m.SessExpires = utc - 10
@ -826,8 +827,8 @@ func TestSession_Expired(t *testing.T) {
assert.Equal(t, m.ExpiresAt(), m.TimeoutAt())
})
t.Run("NotExpired", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour)
utc := UnixTime()
m := NewSession(unix.Day, unix.Hour)
utc := unix.Time()
m.SessExpires = utc + 10

View File

@ -7,34 +7,11 @@ import (
// Day specified as time.Duration to improve readability.
const Day = time.Hour * 24
// UnixMinute is one minute in UnixTime.
const UnixMinute int64 = 60
// UnixHour is one hour in UnixTime.
const UnixHour = UnixMinute * 60
// UnixDay is one day in UnixTime.
const UnixDay = UnixHour * 24
// UnixWeek is one week in UnixTime.
const UnixWeek = UnixDay * 7
// UnixMonth is about one month in UnixTime.
const UnixMonth = UnixDay * 31
// UnixYear is about one year in UnixTime.
const UnixYear = UnixDay * 365
// UTC returns the current Coordinated Universal Time (UTC).
func UTC() time.Time {
return time.Now().UTC()
}
// UnixTime returns the current time in seconds since January 1, 1970 UTC.
func UnixTime() int64 {
return UTC().Unix()
}
// TimeStamp returns the current timestamp in UTC rounded to seconds.
func TimeStamp() time.Time {
return UTC().Truncate(time.Second)

View File

@ -3,15 +3,19 @@ package form
import (
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
)
// Client represents client application settings.
type Client struct {
UserUID string `json:"UserUID,omitempty" yaml:"UserUID,omitempty"`
UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"`
ClientID string `json:"ClientID,omitempty" yaml:"ClientID,omitempty"`
ClientSecret string `json:"ClientSecret,omitempty" yaml:"ClientSecret,omitempty"`
ClientName string `json:"ClientName,omitempty" yaml:"ClientName,omitempty"`
ClientRole string `json:"ClientRole,omitempty" yaml:"ClientRole,omitempty"`
AuthProvider string `json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"`
@ -27,7 +31,10 @@ func NewClient() Client {
return Client{
UserUID: "",
UserName: "",
ClientID: "",
ClientSecret: "",
ClientName: "",
ClientRole: acl.RoleClient.String(),
AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "",
@ -37,29 +44,124 @@ func NewClient() Client {
}
}
// NewClientFromCli creates a new form with values from a CLI context.
func NewClientFromCli(ctx *cli.Context) Client {
// AddClientFromCli creates a new form for adding a client with values from the specified CLI context.
func AddClientFromCli(ctx *cli.Context) Client {
f := NewClient()
f.ClientName = clean.Name(ctx.String("name"))
f.ClientRole = clean.Name(ctx.String("role"))
f.AuthProvider = authn.Provider(ctx.String("provider")).String()
f.AuthMethod = authn.Method(ctx.String("method")).String()
f.AuthScope = clean.Scope(ctx.String("scope"))
if authn.MethodOAuth2.NotEqual(f.AuthMethod) {
f.AuthScope = "webdav"
}
if user := clean.Username(ctx.Args().First()); rnd.IsUID(user, 'u') {
f.UserUID = user
} else if user != "" {
f.UserName = user
}
if ctx.IsSet("id") {
f.ClientID = ctx.String("id")
}
if ctx.IsSet("secret") {
f.ClientSecret = ctx.String("secret")
}
if ctx.IsSet("name") {
f.ClientName = clean.Name(ctx.String("name"))
}
if f.ClientName == "" {
f.ClientName = rnd.Name()
}
f.ClientRole = clean.Name(ctx.String("role"))
if f.ClientRole == "" {
f.ClientRole = acl.RoleClient.String()
}
f.AuthProvider = authn.Provider(ctx.String("provider")).String()
if f.AuthProvider == "" {
f.AuthProvider = authn.ProviderClientCredentials.String()
}
f.AuthMethod = authn.Method(ctx.String("method")).String()
if f.AuthMethod == "" {
f.AuthMethod = authn.MethodOAuth2.String()
}
f.AuthScope = clean.Scope(ctx.String("scope"))
if f.AuthScope == "" {
f.AuthScope = "*"
}
return f
}
// ModClientFromCli creates a new form for modifying a client with values from the specified CLI context.
func ModClientFromCli(ctx *cli.Context) Client {
f := Client{}
f.ClientID = clean.UID(ctx.Args().First())
if ctx.IsSet("secret") {
f.ClientSecret = ctx.String("secret")
}
if ctx.IsSet("name") {
f.ClientName = clean.Name(ctx.String("name"))
}
if ctx.IsSet("role") {
f.ClientRole = clean.Name(ctx.String("role"))
}
if ctx.IsSet("provider") {
f.AuthProvider = authn.Provider(ctx.String("provider")).String()
}
if ctx.IsSet("method") {
f.AuthMethod = authn.Method(ctx.String("method")).String()
}
if ctx.IsSet("scope") {
f.AuthScope = clean.Scope(ctx.String("scope"))
}
if ctx.IsSet("expires") {
f.AuthExpires = ctx.Int64("expires")
}
if ctx.IsSet("tokens") {
f.AuthTokens = ctx.Int64("tokens")
}
if ctx.Bool("enable") {
f.AuthEnabled = true
} else if ctx.Bool("disable") {
f.AuthEnabled = false
}
return f
}
// ID returns the client id, if any.
func (f *Client) ID() string {
if !rnd.IsUID(f.ClientID, 'c') {
return ""
}
return f.ClientID
}
// Secret returns the client secret, if any.
func (f *Client) Secret() string {
if !rnd.IsClientSecret(f.ClientSecret) {
return ""
}
return f.ClientSecret
}
// Name returns the sanitized client name.
func (f *Client) Name() string {
return clean.Name(f.ClientName)
@ -84,3 +186,29 @@ func (f *Client) Method() authn.MethodType {
func (f Client) Scope() string {
return clean.Scope(f.AuthScope)
}
// Expires returns the access token expiry time in seconds or 0 if not specified.
func (f Client) Expires() int64 {
if f.AuthExpires > unix.Month {
return unix.Month
} else if f.AuthExpires > 0 {
return f.AuthExpires
} else if f.AuthExpires < 0 {
return unix.Hour
}
return 0
}
// Tokens returns the access token limit or 0 if not specified.
func (f Client) Tokens() int64 {
if f.AuthTokens > 2147483647 {
return 2147483647
} else if f.AuthTokens > 0 {
return f.AuthTokens
} else if f.AuthTokens < 0 {
return -1
}
return 0
}

View File

@ -20,23 +20,74 @@ func TestNewClient(t *testing.T) {
})
}
func TestNewClientFromCli(t *testing.T) {
func TestAddClientFromCli(t *testing.T) {
// Specify command flags.
flags := flag.NewFlagSet("test", 0)
flags.String("name", "(default)", "Usage")
flags.String("scope", "(default)", "Usage")
flags.String("provider", "(default)", "Usage")
flags.String("method", "(default)", "Usage")
t.Run("Success", func(t *testing.T) {
globalSet := flag.NewFlagSet("test", 0)
globalSet.String("name", "Test", "")
globalSet.String("scope", "*", "")
globalSet.String("provider", "client_credentials", "")
globalSet.String("method", "totp", "")
// Create new context with flags.
ctx := cli.NewContext(cli.NewApp(), flags, nil)
app := cli.NewApp()
app.Version = "0.0.0"
// Set flag values.
assert.NoError(t, ctx.Set("name", "Test"))
assert.NoError(t, ctx.Set("scope", "*"))
assert.NoError(t, ctx.Set("provider", "client_credentials"))
assert.NoError(t, ctx.Set("method", "totp"))
c := cli.NewContext(app, globalSet, nil)
t.Logf("ARGS: %#v", ctx.Args())
client := NewClientFromCli(c)
// Check flag values.
assert.True(t, ctx.IsSet("name"))
assert.Equal(t, "Test", ctx.String("name"))
assert.True(t, ctx.IsSet("provider"))
assert.Equal(t, "client_credentials", ctx.String("provider"))
// Set form values.
client := AddClientFromCli(ctx)
// Check form values.
assert.Equal(t, authn.ProviderClientCredentials, client.Provider())
assert.Equal(t, authn.MethodTOTP, client.Method())
assert.Equal(t, "webdav", client.Scope())
assert.Equal(t, "*", client.Scope())
assert.Equal(t, "Test", client.Name())
})
}
func TestModClientFromCli(t *testing.T) {
// Specify command flags.
flags := flag.NewFlagSet("test", 0)
flags.String("name", "(default)", "Usage")
flags.String("scope", "(default)", "Usage")
flags.String("provider", "(default)", "Usage")
flags.String("method", "(default)", "Usage")
t.Run("Success", func(t *testing.T) {
// Create new context with flags.
ctx := cli.NewContext(cli.NewApp(), flags, nil)
// Set flag values.
assert.NoError(t, ctx.Set("name", "Test"))
assert.NoError(t, ctx.Set("scope", "*"))
assert.NoError(t, ctx.Set("provider", "client_credentials"))
assert.NoError(t, ctx.Set("method", "totp"))
// Check flag values.
assert.True(t, ctx.IsSet("name"))
assert.Equal(t, "Test", ctx.String("name"))
assert.True(t, ctx.IsSet("provider"))
assert.Equal(t, "client_credentials", ctx.String("provider"))
// Set form values.
client := ModClientFromCli(ctx)
// Check form values.
assert.Equal(t, authn.ProviderClientCredentials, client.Provider())
assert.Equal(t, authn.MethodTOTP, client.Method())
assert.Equal(t, "*", client.Scope())
assert.Equal(t, "Test", client.Name())
})
}

View File

@ -6,6 +6,7 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
)
// Session finds an existing session by its id.
@ -31,7 +32,7 @@ func Sessions(limit, offset int, sortOrder, search string) (result entity.Sessio
search = strings.TrimSpace(search)
if search == "expired" {
stmt = stmt.Where("sess_expires > 0 AND sess_expires < ?", entity.UnixTime())
stmt = stmt.Where("sess_expires > 0 AND sess_expires < ?", unix.Time())
} else if rnd.IsSessionID(search) {
stmt = stmt.Where("id = ?", search)
} else if rnd.IsAuthToken(search) {

View File

@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/unix"
)
// Save updates the client session or creates a new one if needed.
@ -15,7 +16,7 @@ func (s *Session) Save(m *entity.Session) (*entity.Session, error) {
}
// Update last active timestamp.
m.LastActive = entity.UnixTime()
m.LastActive = unix.Time()
// Save session.
err := m.Save()

View File

@ -21,6 +21,11 @@ const (
MethodUnknown MethodType = ""
)
// IsUnknown checks if the method is unknown.
func (t MethodType) IsUnknown() bool {
return t == ""
}
// IsDefault checks if this is the default method.
func (t MethodType) IsDefault() bool {
return t.String() == MethodDefault.String()

View File

@ -43,6 +43,11 @@ var ClientProviders = list.List{
string(ProviderAccessToken),
}
// IsUnknown checks if the provider is unknown.
func (t ProviderType) IsUnknown() bool {
return t == ""
}
// IsRemote checks if the provider is external.
func (t ProviderType) IsRemote() bool {
return list.Contains(RemoteProviders, string(t))

View File

@ -4,6 +4,8 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/pkg/unix"
)
const (
@ -48,8 +50,8 @@ var (
func CacheControlMaxAge(maxAge int, public bool) string {
if maxAge < 0 {
return CacheControlNoCache
} else if maxAge > 31536000 {
maxAge = 31536000
} else if maxAge > unix.YearInt {
maxAge = unix.YearInt
}
switch {

View File

@ -6,8 +6,9 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/unix"
)
func TestCacheControlMaxAge(t *testing.T) {
@ -15,27 +16,27 @@ func TestCacheControlMaxAge(t *testing.T) {
assert.Equal(t, CacheControlPrivateDefault, CacheControlMaxAge(0, false))
assert.Equal(t, "no-cache", CacheControlMaxAge(-1, false))
assert.Equal(t, "private, max-age=1", CacheControlMaxAge(1, false))
assert.Equal(t, "private, max-age=31536000", CacheControlMaxAge(31536000, false))
assert.Equal(t, "private, max-age=31536000", CacheControlMaxAge(unix.YearInt, false))
assert.Equal(t, "private, max-age=31536000", CacheControlMaxAge(1231536000, false))
})
t.Run("Public", func(t *testing.T) {
assert.Equal(t, CacheControlPublicDefault, CacheControlMaxAge(0, true))
assert.Equal(t, "no-cache", CacheControlMaxAge(-1, true))
assert.Equal(t, "public, max-age=1", CacheControlMaxAge(1, true))
assert.Equal(t, "public, max-age=31536000", CacheControlMaxAge(31536000, true))
assert.Equal(t, "public, max-age=31536000", CacheControlMaxAge(unix.YearInt, true))
assert.Equal(t, "public, max-age=31536000", CacheControlMaxAge(1231536000, true))
})
}
func BenchmarkTestCacheControlMaxAge(b *testing.B) {
for n := 0; n < b.N; n++ {
_ = CacheControlMaxAge(31536000, false)
_ = CacheControlMaxAge(unix.YearInt, false)
}
}
func BenchmarkTestCacheControlMaxAgeImmutable(b *testing.B) {
for n := 0; n < b.N; n++ {
_ = CacheControlMaxAge(31536000, false) + ", " + CacheControlImmutable
_ = CacheControlMaxAge(unix.YearInt, false) + ", " + CacheControlImmutable
}
}
@ -48,7 +49,7 @@ func TestSetCacheControl(t *testing.T) {
Header: make(http.Header),
}
SetCacheControl(c, 31536000, false)
SetCacheControl(c, unix.YearInt, false)
assert.Equal(t, "private, max-age=31536000", c.Writer.Header().Get(CacheControl))
})
t.Run("Public", func(t *testing.T) {
@ -59,7 +60,7 @@ func TestSetCacheControl(t *testing.T) {
Header: make(http.Header),
}
SetCacheControl(c, 31536000, true)
SetCacheControl(c, unix.YearInt, true)
assert.Equal(t, "public, max-age=31536000", c.Writer.Header().Get(CacheControl))
})
t.Run("NoCache", func(t *testing.T) {
@ -84,7 +85,7 @@ func TestSetCacheControlImmutable(t *testing.T) {
Header: make(http.Header),
}
SetCacheControlImmutable(c, 31536000, false)
SetCacheControlImmutable(c, unix.YearInt, false)
assert.Equal(t, "private, max-age=31536000, immutable", c.Writer.Header().Get(CacheControl))
})
t.Run("Public", func(t *testing.T) {
@ -95,7 +96,7 @@ func TestSetCacheControlImmutable(t *testing.T) {
Header: make(http.Header),
}
SetCacheControlImmutable(c, 31536000, true)
SetCacheControlImmutable(c, unix.YearInt, true)
assert.Equal(t, "public, max-age=31536000, immutable", c.Writer.Header().Get(CacheControl))
})
t.Run("PublicDefault", func(t *testing.T) {

View File

@ -1,5 +1,5 @@
/*
Package sev provides event importance levels and parsers.
Package level provides constants and abstractions for log levels and severities.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
@ -22,16 +22,4 @@ want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package sev
// Level represents the severity of an event.
type Level uint8
// String returns the severity level as a string, e.g. Alert becomes "alert".
func (level Level) String() string {
if b, err := level.MarshalText(); err == nil {
return string(b)
} else {
return "unknown"
}
}
package level

View File

@ -1,11 +1,12 @@
package sev
package level
import (
"fmt"
)
// Severity levels.
const (
Emergency Level = iota
Emergency Severity = iota
Alert
Critical
Error
@ -15,7 +16,8 @@ const (
Debug
)
var Levels = []Level{
// Levels contains the valid severity levels.
var Levels = []Severity{
Emergency,
Alert,
Critical,
@ -27,7 +29,7 @@ var Levels = []Level{
}
// UnmarshalText implements encoding.TextUnmarshaler.
func (level *Level) UnmarshalText(text []byte) error {
func (level *Severity) UnmarshalText(text []byte) error {
l, err := Parse(string(text))
if err != nil {
return err
@ -38,7 +40,8 @@ func (level *Level) UnmarshalText(text []byte) error {
return nil
}
func (level Level) MarshalText() ([]byte, error) {
// MarshalText implements encoding.TextMarshaler.
func (level Severity) MarshalText() ([]byte, error) {
switch level {
case Debug:
return []byte("debug"), nil
@ -61,7 +64,8 @@ func (level Level) MarshalText() ([]byte, error) {
return nil, fmt.Errorf("not a valid severity level %d", level)
}
func (level Level) Status() string {
// Status returns the severity level as an info string for reports.
func (level Severity) Status() string {
switch level {
case Warning:
return "warning"

View File

@ -1,10 +1,10 @@
package sev
package level
import "github.com/sirupsen/logrus"
// LogLevel takes a logrus log level and returns the severity.
func LogLevel(lvl logrus.Level) Level {
switch lvl {
// Logrus takes a logrus.Level and returns the corresponding Severity.
func Logrus(level logrus.Level) Severity {
switch level {
case logrus.PanicLevel:
return Alert
case logrus.FatalLevel:

View File

@ -1,13 +1,13 @@
package sev
package level
import (
"fmt"
"strings"
)
// Parse takes a string level and returns the severity constant.
func Parse(lvl string) (Level, error) {
switch strings.ToLower(lvl) {
// Parse takes a string and returns the corresponding severity, if any.
func Parse(level string) (Severity, error) {
switch strings.ToLower(level) {
case "emergency", "emerg", "panic":
return Emergency, nil
case "fatal", "alert":
@ -26,6 +26,6 @@ func Parse(lvl string) (Level, error) {
return Debug, nil
}
var l Level
return l, fmt.Errorf("not a valid Level: %q", lvl)
var l Severity
return l, fmt.Errorf("not a valid severity level: %q", level)
}

13
pkg/level/severity.go Normal file
View File

@ -0,0 +1,13 @@
package level
// Severity represents the severity level of an event.
type Severity uint8
// String returns the severity level as a string, e.g. Alert becomes "alert".
func (level Severity) String() string {
if b, err := level.MarshalText(); err == nil {
return string(b)
} else {
return "unknown"
}
}

View File

@ -1,4 +1,4 @@
package sev
package level
import (
"bytes"
@ -10,7 +10,7 @@ import (
func TestLevelJsonEncoding(t *testing.T) {
type X struct {
Level Level
Level Severity
}
var x X
@ -24,7 +24,7 @@ func TestLevelJsonEncoding(t *testing.T) {
}
func TestLevelUnmarshalText(t *testing.T) {
var u Level
var u Severity
for _, level := range Levels {
t.Run(level.String(), func(t *testing.T) {
assert.NoError(t, u.UnmarshalText([]byte(level.String())))
@ -50,7 +50,7 @@ func TestLevelMarshalText(t *testing.T) {
for idx, val := range Levels {
level := val
t.Run(level.String(), func(t *testing.T) {
var cmp Level
var cmp Severity
b, err := level.MarshalText()
assert.NoError(t, err)
assert.Equal(t, levelStrings[idx], string(b))

View File

@ -29,7 +29,7 @@ func AuthToken() string {
return fmt.Sprintf("%x", b)
}
// IsAuthToken checks if the string might be a valid auth token.
// IsAuthToken checks if the string represents a valid auth token.
func IsAuthToken(s string) bool {
if l := len(s); l == AuthTokenLength {
return IsHex(s)
@ -61,7 +61,7 @@ func AppPassword() string {
return string(b)
}
// IsAppPassword checks if the string might be a valid app password.
// IsAppPassword checks if the string represents a valid app password.
func IsAppPassword(s string, verifyChecksum bool) bool {
// Verify token length.
if len(s) != AppPasswordLength {
@ -89,7 +89,7 @@ func IsAppPassword(s string, verifyChecksum bool) bool {
return s[AppPasswordLength-1] == checksum.Char([]byte(s[:AppPasswordLength-1]))
}
// IsAuthAny checks if the string might be a valid auth token or app password.
// IsAuthAny checks if the string represents a valid auth token or app password.
func IsAuthAny(s string) bool {
// Check if string might be a regular auth token.
if IsAuthToken(s) {
@ -109,7 +109,7 @@ func SessionID(token string) string {
return Sha256([]byte(token))
}
// IsSessionID checks if the string is a session id string.
// IsSessionID checks if the string represents a valid session id.
func IsSessionID(id string) bool {
if len(id) != SessionIdLength {
return false

19
pkg/rnd/client.go Normal file
View File

@ -0,0 +1,19 @@
package rnd
const (
ClientSecretLength = 32
)
// ClientSecret generates a random client secret containing 32 upper and lower case letters as well as numbers.
func ClientSecret() string {
return Base62(ClientSecretLength)
}
// IsClientSecret checks if the string represents a valid client secret.
func IsClientSecret(s string) bool {
if l := len(s); l == ClientSecretLength {
return IsAlnum(s)
}
return false
}

48
pkg/rnd/client_test.go Normal file
View File

@ -0,0 +1,48 @@
package rnd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestClientSecret(t *testing.T) {
result := ClientSecret()
assert.Equal(t, ClientSecretLength, len(result))
assert.NotEqual(t, AuthTokenLength, len(result))
assert.True(t, IsClientSecret(result))
assert.False(t, IsAuthToken(result))
assert.False(t, IsHex(result))
for n := 0; n < 10; n++ {
s := ClientSecret()
t.Logf("ClientSecret %d: %s", n, s)
assert.True(t, IsClientSecret(s))
}
}
func BenchmarkClientSecret(b *testing.B) {
for n := 0; n < b.N; n++ {
ClientSecret()
}
}
func TestIsClientSecret(t *testing.T) {
assert.True(t, IsClientSecret(ClientSecret()))
assert.True(t, IsClientSecret("69be27ac5ca305b394046a83f6fda181"))
assert.False(t, IsClientSecret("MPkOqm-RtKGOi-ctIvXm-Qv3XhN"))
assert.False(t, IsClientSecret("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2"))
assert.False(t, IsClientSecret(AuthToken()))
assert.False(t, IsClientSecret(AuthToken()))
assert.False(t, IsClientSecret(SessionID(AuthToken())))
assert.False(t, IsClientSecret(SessionID(AuthToken())))
assert.False(t, IsClientSecret("55785BAC-9H4B-4747-B090-EE123FFEE437"))
assert.True(t, IsClientSecret("4B1FEF2D1CF4A5BE38B263E0637EDEAD"))
assert.False(t, IsClientSecret(""))
}
func BenchmarkIsClientSecret(b *testing.B) {
for n := 0; n < b.N; n++ {
IsClientSecret("69be27ac5ca305b394046a83f6fda181")
}
}

22
pkg/unix/const.go Normal file
View File

@ -0,0 +1,22 @@
package unix
// Minute is one minute in seconds.
const Minute int64 = 60
// Hour is one hour in seconds.
const Hour = Minute * 60
// Day is one day in seconds.
const Day = Hour * 24
// Week is one week in seconds.
const Week = Day * 7
// Month is about one month in seconds.
const Month = Day * 31
// Year is 365 days in seconds.
const Year = Day * 365
// YearInt is Year specified as integer.
const YearInt = int(Year)

8
pkg/unix/time.go Normal file
View File

@ -0,0 +1,8 @@
package unix
import "time"
// Time returns the current time in seconds since January 1, 1970 UTC.
func Time() int64 {
return time.Now().UTC().Unix()
}

16
pkg/unix/time_test.go Normal file
View File

@ -0,0 +1,16 @@
package unix
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestTime(t *testing.T) {
result := Time()
assert.Greater(t, result, int64(1706521797))
assert.GreaterOrEqual(t, result, time.Now().UTC().Unix())
assert.LessOrEqual(t, result, time.Now().UTC().Unix()+2)
}

25
pkg/unix/unix.go Normal file
View File

@ -0,0 +1,25 @@
/*
Package unix provides constants and functions for Unix timestamps.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package unix