多租户更改
This commit is contained in:
@@ -38,7 +38,10 @@ func (a *authenticator) IsInitialized() bool {
|
||||
|
||||
// AddRecord checks authLevel and assigns default LevelAnon. Otherwise it
|
||||
// just reports success.
|
||||
func (authenticator) AddRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {
|
||||
func (authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
if err := auth.BindRecord(ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rec.AuthLevel == auth.LevelNone {
|
||||
rec.AuthLevel = auth.LevelAnon
|
||||
}
|
||||
@@ -47,12 +50,12 @@ func (authenticator) AddRecord(rec *auth.Rec, secret []byte, remoteAddr string)
|
||||
}
|
||||
|
||||
// UpdateRecord is a noop. Just report success.
|
||||
func (authenticator) UpdateRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {
|
||||
return rec, nil
|
||||
func (authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
return rec, auth.BindRecord(ctx, rec)
|
||||
}
|
||||
|
||||
// Authenticate is not supported. This authenticator is used only at account creation time.
|
||||
func (authenticator) Authenticate(secret []byte, remoteAddr string) (*auth.Rec, []byte, error) {
|
||||
func (authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth.Rec, []byte, error) {
|
||||
return nil, nil, types.ErrUnsupported
|
||||
}
|
||||
|
||||
@@ -62,7 +65,10 @@ func (authenticator) AsTag(token string) string {
|
||||
}
|
||||
|
||||
// IsUnique for a noop. Anonymous login does not use secret, any secret is fine.
|
||||
func (authenticator) IsUnique(secret []byte, remoteAddr string) (bool, error) {
|
||||
func (authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, error) {
|
||||
if !ctx.IsValid() {
|
||||
return false, types.ErrMalformed
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -72,7 +78,7 @@ func (authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
|
||||
}
|
||||
|
||||
// DelRecords is a noop which always succeeds.
|
||||
func (authenticator) DelRecords(uid types.Uid) error {
|
||||
func (authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -83,7 +89,7 @@ func (authenticator) RestrictedTags() ([]string, error) {
|
||||
|
||||
// GetResetParams returns authenticator parameters passed to password reset handler
|
||||
// (none for anonymous).
|
||||
func (authenticator) GetResetParams(uid types.Uid) (map[string]any, error) {
|
||||
func (authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
// Package auth provides interfaces and types required for implementing an authenticaor.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
// Level is the type for authentication levels.
|
||||
type Level int
|
||||
|
||||
// Authentication levels
|
||||
const (
|
||||
// LevelNone is undefined/not authenticated
|
||||
LevelNone Level = iota * 10
|
||||
// LevelAnon is anonymous user/light authentication
|
||||
LevelAnon
|
||||
// LevelAuth is fully authenticated user
|
||||
LevelAuth
|
||||
// LevelRoot is a superuser (currently unused)
|
||||
LevelRoot
|
||||
)
|
||||
|
||||
// String implements Stringer interface: gets human-readable name for a numeric authentication level.
|
||||
func (a Level) String() string {
|
||||
s, err := a.MarshalText()
|
||||
if err != nil {
|
||||
return "unkn"
|
||||
}
|
||||
return string(s)
|
||||
}
|
||||
|
||||
// ParseAuthLevel parses authentication level from a string.
|
||||
func ParseAuthLevel(name string) Level {
|
||||
switch name {
|
||||
case "anon", "ANON":
|
||||
return LevelAnon
|
||||
case "auth", "AUTH":
|
||||
return LevelAuth
|
||||
case "root", "ROOT":
|
||||
return LevelRoot
|
||||
default:
|
||||
return LevelNone
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalText converts Level to a slice of bytes with the name of the level.
|
||||
func (a Level) MarshalText() ([]byte, error) {
|
||||
switch a {
|
||||
case LevelNone:
|
||||
return []byte(""), nil
|
||||
case LevelAnon:
|
||||
return []byte("anon"), nil
|
||||
case LevelAuth:
|
||||
return []byte("auth"), nil
|
||||
case LevelRoot:
|
||||
return []byte("root"), nil
|
||||
default:
|
||||
return nil, errors.New("auth.Level: invalid level value")
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalText parses authentication level from a string.
|
||||
func (a *Level) UnmarshalText(b []byte) error {
|
||||
switch string(b) {
|
||||
case "":
|
||||
*a = LevelNone
|
||||
return nil
|
||||
case "anon", "ANON":
|
||||
*a = LevelAnon
|
||||
return nil
|
||||
case "auth", "AUTH":
|
||||
*a = LevelAuth
|
||||
return nil
|
||||
case "root", "ROOT":
|
||||
*a = LevelRoot
|
||||
return nil
|
||||
default:
|
||||
return errors.New("auth.Level: unrecognized")
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON converts Level to a quoted string.
|
||||
func (a Level) MarshalJSON() ([]byte, error) {
|
||||
res, err := a.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(append([]byte{'"'}, res...), '"'), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads Level from a quoted string.
|
||||
func (a *Level) UnmarshalJSON(b []byte) error {
|
||||
if b[0] != '"' || b[len(b)-1] != '"' {
|
||||
return errors.New("syntax error")
|
||||
}
|
||||
|
||||
return a.UnmarshalText(b[1 : len(b)-1])
|
||||
}
|
||||
|
||||
// Feature is a bitmap of authenticated features, such as validated/not validated.
|
||||
type Feature uint16
|
||||
|
||||
const (
|
||||
// FeatureValidated bit is set if user's credentials are already validated (V).
|
||||
FeatureValidated Feature = 1 << iota
|
||||
// FeatureNoLogin is set if the token should not be used to permanently authenticate a session (L).
|
||||
FeatureNoLogin
|
||||
)
|
||||
|
||||
// MarshalText converts Feature to ASCII byte slice.
|
||||
func (f Feature) MarshalText() ([]byte, error) {
|
||||
res := []byte{}
|
||||
for i, chr := range []byte{'V', 'L'} {
|
||||
if (f & (1 << uint(i))) != 0 {
|
||||
res = append(res, chr)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UnmarshalText parses Feature string as byte slice.
|
||||
func (f *Feature) UnmarshalText(b []byte) error {
|
||||
var f0 int
|
||||
var err error
|
||||
if len(b) > 0 {
|
||||
if b[0] >= '0' && b[0] <= '9' {
|
||||
f0, err = strconv.Atoi(string(b))
|
||||
} else {
|
||||
Loop:
|
||||
for i := range b {
|
||||
switch b[i] {
|
||||
case 'V', 'v':
|
||||
f0 |= int(FeatureValidated)
|
||||
case 'L', 'l':
|
||||
f0 |= int(FeatureNoLogin)
|
||||
default:
|
||||
err = errors.New("Feature: invalid character '" + string(b[i]) + "'")
|
||||
break Loop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*f = Feature(f0)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// String Featureto a string representation.
|
||||
func (f Feature) String() string {
|
||||
res, err := f.MarshalText()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(res)
|
||||
}
|
||||
|
||||
// MarshalJSON converts Feature to a quoted string.
|
||||
func (f Feature) MarshalJSON() ([]byte, error) {
|
||||
res, err := f.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(append([]byte{'"'}, res...), '"'), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads Feature from a quoted string or an integer.
|
||||
func (f *Feature) UnmarshalJSON(b []byte) error {
|
||||
if b[0] == '"' && b[len(b)-1] == '"' {
|
||||
return f.UnmarshalText(b[1 : len(b)-1])
|
||||
}
|
||||
return f.UnmarshalText(b)
|
||||
}
|
||||
|
||||
// Duration is identical to time.Duration except it can be sanely unmarshallend from JSON.
|
||||
type Duration time.Duration
|
||||
|
||||
// AuthContext is server-owned context for an authentication operation.
|
||||
// TenantID must be resolved before invoking an authenticator.
|
||||
type AuthContext struct {
|
||||
TenantID types.TenantID
|
||||
RemoteAddr string
|
||||
}
|
||||
|
||||
// IsValid reports whether the authentication context is bound to a tenant.
|
||||
func (ctx AuthContext) IsValid() bool {
|
||||
return !ctx.TenantID.IsZero()
|
||||
}
|
||||
|
||||
// BindRecord validates the authentication context and binds a record to it.
|
||||
// A record which is already bound to another tenant is rejected.
|
||||
func BindRecord(ctx AuthContext, rec *Rec) error {
|
||||
if !ctx.IsValid() || rec == nil {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
if !rec.TenantID.IsZero() && rec.TenantID != ctx.TenantID {
|
||||
return types.ErrFailed
|
||||
}
|
||||
rec.TenantID = ctx.TenantID
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON handles the cases where duration is specified in JSON as a "5000s" string or just plain seconds.
|
||||
func (d *Duration) UnmarshalJSON(b []byte) error {
|
||||
var v any
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch value := v.(type) {
|
||||
case float64:
|
||||
*d = Duration(time.Duration(value) * time.Second)
|
||||
return nil
|
||||
case string:
|
||||
d0, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Duration(d0)
|
||||
return nil
|
||||
default:
|
||||
return errors.New("invalid duration")
|
||||
}
|
||||
}
|
||||
|
||||
// Rec is an authentication record.
|
||||
type Rec struct {
|
||||
// Tenant which owns this authentication record.
|
||||
TenantID types.TenantID `json:"tenant_id,omitempty"`
|
||||
// User ID.
|
||||
Uid types.Uid `json:"uid,omitempty"`
|
||||
// Authentication level.
|
||||
AuthLevel Level `json:"authlvl,omitempty"`
|
||||
// Lifetime of this record.
|
||||
Lifetime Duration `json:"lifetime,omitempty"`
|
||||
// Bitmap of features. Currently 'validated'/'not validated' only.
|
||||
Features Feature `json:"features,omitempty"`
|
||||
// Tags generated by this authentication record.
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
// User account state received or read by the authenticator.
|
||||
State types.ObjState
|
||||
// Credential 'method:value' associated with this record.
|
||||
Credential string `json:"cred,omitempty"`
|
||||
|
||||
// Authenticator may request the server to create a new account.
|
||||
// These are the account parameters which can be used for creating the account.
|
||||
DefAcs *types.DefaultAccess `json:"defacs,omitempty"`
|
||||
Public any `json:"public,omitempty"`
|
||||
Private any `json:"private,omitempty"`
|
||||
}
|
||||
|
||||
// AuthHandler is the interface which auth providers must implement.
|
||||
type AuthHandler interface {
|
||||
// Init initializes the handler taking config string and logical name as parameters.
|
||||
Init(jsonconf json.RawMessage, name string) error
|
||||
|
||||
// IsInitialized returns true if the handler is initialized.
|
||||
IsInitialized() bool
|
||||
|
||||
// AddRecord adds persistent authentication record to the database.
|
||||
// Returns: updated auth record, error
|
||||
AddRecord(ctx AuthContext, rec *Rec, secret []byte) (*Rec, error)
|
||||
|
||||
// UpdateRecord updates existing record with new credentials.
|
||||
// Returns updated auth record, error.
|
||||
UpdateRecord(ctx AuthContext, rec *Rec, secret []byte) (*Rec, error)
|
||||
|
||||
// Authenticate: given a user-provided authentication secret (such as "login:password"), either
|
||||
// return user's record (ID, time when the secret expires, etc), or issue a challenge to
|
||||
// continue the authentication process to the next step, or return an error code.
|
||||
// The remoteAddr (i.e. the IP address of the client) can be used by custom authenticators for
|
||||
// additional validation. The stock authenticators don't use it.
|
||||
// store.Users.GetAuthRecord("scheme", "unique")
|
||||
// Returns: user auth record, challenge, error.
|
||||
Authenticate(ctx AuthContext, secret []byte) (*Rec, []byte, error)
|
||||
|
||||
// AsTag converts search token into prefixed tag or an empty string if it
|
||||
// cannot be represented as a prefixed tag.
|
||||
AsTag(token string) string
|
||||
|
||||
// IsUnique verifies if the provided secret can be considered unique by the auth scheme
|
||||
// E.g. if login is unique. It also may check for policy compliance, i.e. not too short, etc.
|
||||
IsUnique(ctx AuthContext, secret []byte) (bool, error)
|
||||
|
||||
// GenSecret generates a new secret, if appropriate.
|
||||
GenSecret(rec *Rec) ([]byte, time.Time, error)
|
||||
|
||||
// DelRecords deletes (or disables) all authentication records for the given user.
|
||||
DelRecords(tenantID types.TenantID, uid types.Uid) error
|
||||
|
||||
// RestrictedTags returns the tag namespaces (prefixes) which are restricted by this authenticator.
|
||||
RestrictedTags() ([]string, error)
|
||||
|
||||
// GetResetParams returns authenticator parameters passed to password reset handler
|
||||
// for the provided user id.
|
||||
// Returns: map of params.
|
||||
GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error)
|
||||
|
||||
// GetRealName returns the hardcoded name of the authenticator.
|
||||
GetRealName() string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func TestBindRecord(t *testing.T) {
|
||||
ctx := AuthContext{TenantID: 7, RemoteAddr: "127.0.0.1"}
|
||||
rec := &Rec{}
|
||||
if err := BindRecord(ctx, rec); err != nil {
|
||||
t.Fatalf("BindRecord failed: %v", err)
|
||||
}
|
||||
if rec.TenantID != ctx.TenantID {
|
||||
t.Fatalf("unexpected tenant: got %d, want %d", rec.TenantID, ctx.TenantID)
|
||||
}
|
||||
|
||||
if err := BindRecord(AuthContext{}, &Rec{}); err != types.ErrMalformed {
|
||||
t.Fatalf("zero tenant: got %v, want %v", err, types.ErrMalformed)
|
||||
}
|
||||
if err := BindRecord(ctx, &Rec{TenantID: 8}); err != types.ErrFailed {
|
||||
t.Fatalf("tenant mismatch: got %v, want %v", err, types.ErrFailed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// Package basic is an authenticator by login-password.
|
||||
package basic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/auth"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Define default constraints on login and password
|
||||
const (
|
||||
defaultMinLoginLength = 2
|
||||
defaultMaxLoginLength = 32
|
||||
|
||||
defaultMinPasswordLength = 3
|
||||
)
|
||||
|
||||
// Token suitable as a login: starts and ends with a Unicode letter (class L) or number (class N),
|
||||
// contains Unicode letters, numbers, dot and underscore.
|
||||
var loginPattern = regexp.MustCompile(`^[\pL\pN][_.\pL\pN]*[\pL\pN]+$`)
|
||||
|
||||
// authenticator is the type to map authentication methods to.
|
||||
type authenticator struct {
|
||||
name string
|
||||
addToTags bool
|
||||
|
||||
minPasswordLength int
|
||||
minLoginLength int
|
||||
}
|
||||
|
||||
func (a *authenticator) checkLoginPolicy(uname string) error {
|
||||
rlogin := []rune(uname)
|
||||
if len(rlogin) < a.minLoginLength || len(rlogin) > defaultMaxLoginLength || !loginPattern.MatchString(uname) {
|
||||
return types.ErrPolicy
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *authenticator) checkPasswordPolicy(password string) error {
|
||||
if len([]rune(password)) < a.minPasswordLength {
|
||||
return types.ErrPolicy
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSecret(bsecret []byte) (uname, password string, err error) {
|
||||
secret := string(bsecret)
|
||||
|
||||
splitAt := strings.Index(secret, ":")
|
||||
if splitAt < 0 {
|
||||
err = types.ErrMalformed
|
||||
return
|
||||
}
|
||||
|
||||
uname = strings.ToLower(secret[:splitAt])
|
||||
password = secret[splitAt+1:]
|
||||
return
|
||||
}
|
||||
|
||||
// Init initializes the basic authenticator.
|
||||
func (a *authenticator) Init(jsonconf json.RawMessage, name string) error {
|
||||
if name == "" {
|
||||
return errors.New("auth_basic: authenticator name cannot be blank")
|
||||
}
|
||||
|
||||
if a.name != "" {
|
||||
return errors.New("auth_basic: already initialized as " + a.name + "; " + name)
|
||||
}
|
||||
|
||||
type configType struct {
|
||||
// AddToTags indicates that the user name should be used as a searchable tag.
|
||||
AddToTags bool `json:"add_to_tags"`
|
||||
MinPasswordLength int `json:"min_password_length"`
|
||||
MinLoginLength int `json:"min_login_length"`
|
||||
}
|
||||
|
||||
var config configType
|
||||
if err := json.Unmarshal(jsonconf, &config); err != nil {
|
||||
return errors.New("auth_basic: failed to parse config: " + err.Error() + "(" + string(jsonconf) + ")")
|
||||
}
|
||||
a.name = name
|
||||
a.addToTags = config.AddToTags
|
||||
a.minPasswordLength = config.MinPasswordLength
|
||||
if a.minPasswordLength <= 0 {
|
||||
a.minPasswordLength = defaultMinPasswordLength
|
||||
}
|
||||
a.minLoginLength = config.MinLoginLength
|
||||
if a.minLoginLength > defaultMaxLoginLength {
|
||||
return errors.New("auth_basic: min_login_length exceeds the limit")
|
||||
}
|
||||
if a.minLoginLength <= 0 {
|
||||
a.minLoginLength = defaultMinLoginLength
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsInitialized returns true if the handler is initialized.
|
||||
func (a *authenticator) IsInitialized() bool {
|
||||
return a.name != ""
|
||||
}
|
||||
|
||||
// AddRecord adds a basic authentication record to DB.
|
||||
func (a *authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
if err := auth.BindRecord(ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uname, password, err := parseSecret(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = a.checkLoginPolicy(uname); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = a.checkPasswordPolicy(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
passhash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var expires time.Time
|
||||
if rec.Lifetime > 0 {
|
||||
expires = time.Now().Add(time.Duration(rec.Lifetime)).UTC().Round(time.Millisecond)
|
||||
}
|
||||
|
||||
authLevel := rec.AuthLevel
|
||||
if authLevel == auth.LevelNone {
|
||||
authLevel = auth.LevelAuth
|
||||
}
|
||||
|
||||
err = store.Users.AddAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rec.AuthLevel = authLevel
|
||||
if a.addToTags {
|
||||
rec.Tags = append(rec.Tags, a.name+":"+uname)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// UpdateRecord updates password for basic authentication.
|
||||
func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
if err := auth.BindRecord(ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uname, password, err := parseSecret(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
login, authLevel, _, _, err := store.Users.GetAuthRecord(ctx.TenantID, rec.Uid, a.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// User does not have a record.
|
||||
if login == "" {
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
|
||||
if uname == "" || uname == login {
|
||||
// User is changing just the password.
|
||||
uname = login
|
||||
} else if err = a.checkLoginPolicy(uname); err != nil {
|
||||
return nil, err
|
||||
} else if uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname); err != nil {
|
||||
return nil, err
|
||||
} else if !uid.IsZero() {
|
||||
// The (new) user name already exists. Report an error.
|
||||
return nil, types.ErrDuplicate
|
||||
}
|
||||
|
||||
if err = a.checkPasswordPolicy(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
passhash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
var expires time.Time
|
||||
if rec.Lifetime > 0 {
|
||||
expires = types.TimeNow().Add(time.Duration(rec.Lifetime))
|
||||
}
|
||||
err = store.Users.UpdateAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Remove old tag from the list of tags
|
||||
oldTag := a.name + ":" + login
|
||||
for i, tag := range rec.Tags {
|
||||
if tag == oldTag {
|
||||
rec.Tags[i] = rec.Tags[len(rec.Tags)-1]
|
||||
rec.Tags = rec.Tags[:len(rec.Tags)-1]
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
// Add new tag
|
||||
rec.Tags = append(rec.Tags, a.name+":"+uname)
|
||||
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Authenticate checks login and password.
|
||||
func (a *authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth.Rec, []byte, error) {
|
||||
if !ctx.IsValid() {
|
||||
return nil, nil, types.ErrMalformed
|
||||
}
|
||||
uname, password, err := parseSecret(secret)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
uid, authLvl, passhash, expires, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if uid.IsZero() {
|
||||
// Invalid login.
|
||||
return nil, nil, types.ErrFailed
|
||||
}
|
||||
if !expires.IsZero() && expires.Before(time.Now()) {
|
||||
// The record has expired
|
||||
return nil, nil, types.ErrExpired
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword(passhash, []byte(password))
|
||||
if err != nil {
|
||||
// Invalid password
|
||||
return nil, nil, types.ErrFailed
|
||||
}
|
||||
|
||||
var lifetime time.Duration
|
||||
if !expires.IsZero() {
|
||||
lifetime = time.Until(expires)
|
||||
}
|
||||
return &auth.Rec{
|
||||
TenantID: ctx.TenantID,
|
||||
Uid: uid,
|
||||
AuthLevel: authLvl,
|
||||
Lifetime: auth.Duration(lifetime),
|
||||
Features: 0,
|
||||
State: types.StateUndefined}, nil, nil
|
||||
}
|
||||
|
||||
// AsTag convert search token into a prefixed tag, if possible.
|
||||
func (a *authenticator) AsTag(token string) string {
|
||||
if !a.addToTags {
|
||||
return ""
|
||||
}
|
||||
|
||||
if err := a.checkLoginPolicy(token); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return a.name + ":" + token
|
||||
}
|
||||
|
||||
// IsUnique checks login uniqueness and policy compliance.
|
||||
func (a *authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, error) {
|
||||
if !ctx.IsValid() {
|
||||
return false, types.ErrMalformed
|
||||
}
|
||||
uname, _, err := parseSecret(secret)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := a.checkLoginPolicy(uname); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if uid.IsZero() {
|
||||
return true, nil
|
||||
}
|
||||
return false, types.ErrDuplicate
|
||||
}
|
||||
|
||||
// GenSecret is not supported, generates an error.
|
||||
func (authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
|
||||
return nil, time.Time{}, types.ErrUnsupported
|
||||
}
|
||||
|
||||
// DelRecords deletes saved authentication records of the given user.
|
||||
func (a *authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
|
||||
return store.Users.DelAuthRecords(tenantID, uid, a.name)
|
||||
}
|
||||
|
||||
// RestrictedTags returns tag namespaces (prefixes) restricted by this adapter.
|
||||
func (a *authenticator) RestrictedTags() ([]string, error) {
|
||||
var prefix []string
|
||||
if a.addToTags {
|
||||
prefix = []string{a.name}
|
||||
}
|
||||
return prefix, nil
|
||||
}
|
||||
|
||||
// GetResetParams returns authenticator parameters passed to password reset handler.
|
||||
func (a *authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
|
||||
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, uid, a.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// User does not have a record matching the authentication scheme.
|
||||
if login == "" {
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
|
||||
params := make(map[string]any)
|
||||
params["login"] = login
|
||||
return params, nil
|
||||
}
|
||||
|
||||
const realName = "basic"
|
||||
|
||||
// GetRealName returns the hardcoded name of the authenticator.
|
||||
func (authenticator) GetRealName() string {
|
||||
return realName
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.RegisterAuthScheme(realName, &authenticator{})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/tinode/chat/server/auth"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/mock_store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAuthenticateScopesLoginByTenant(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
||||
oldUsers := store.Users
|
||||
store.Users = users
|
||||
t.Cleanup(func() {
|
||||
store.Users = oldUsers
|
||||
ctrl.Finish()
|
||||
})
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users.EXPECT().GetAuthUniqueRecord(types.TenantID(7), "basic", "alice").
|
||||
Return(types.Uid(70), auth.LevelAuth, hash, time.Time{}, nil)
|
||||
users.EXPECT().GetAuthUniqueRecord(types.TenantID(8), "basic", "alice").
|
||||
Return(types.Uid(80), auth.LevelAuth, hash, time.Time{}, nil)
|
||||
|
||||
a := &authenticator{name: "basic", minLoginLength: defaultMinLoginLength,
|
||||
minPasswordLength: defaultMinPasswordLength}
|
||||
recA, _, err := a.Authenticate(auth.AuthContext{TenantID: 7}, []byte("alice:password"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recB, _, err := a.Authenticate(auth.AuthContext{TenantID: 8}, []byte("alice:password"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recA.TenantID != 7 || recA.Uid != 70 || recB.TenantID != 8 || recB.Uid != 80 {
|
||||
t.Fatalf("unexpected records: A=%+v B=%+v", recA, recB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateRejectsMissingTenant(t *testing.T) {
|
||||
a := &authenticator{name: "basic", minLoginLength: defaultMinLoginLength,
|
||||
minPasswordLength: defaultMinPasswordLength}
|
||||
if _, _, err := a.Authenticate(auth.AuthContext{}, []byte("alice:password")); err != types.ErrMalformed {
|
||||
t.Fatalf("got %v, want %v", err, types.ErrMalformed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Package code implements temporary no-login authentication by short numeric code.
|
||||
package code
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/auth"
|
||||
"github.com/tinode/chat/server/logs"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
// authenticator is a singleton instance of the authenticator.
|
||||
type authenticator struct {
|
||||
name string
|
||||
codeLength int
|
||||
maxCodeValue *big.Int
|
||||
lifetime time.Duration
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
// Init initializes the authenticator: parses the config and sets internal state.
|
||||
func (ca *authenticator) Init(jsonconf json.RawMessage, name string) error {
|
||||
if name == "" {
|
||||
return errors.New("auth_code: authenticator name cannot be blank")
|
||||
}
|
||||
|
||||
if ca.name != "" {
|
||||
return errors.New("auth_code: already initialized as " + ca.name + "; " + name)
|
||||
}
|
||||
|
||||
type configType struct {
|
||||
// Length of the security code.
|
||||
CodeLength int `json:"code_length"`
|
||||
// Code expiration time in seconds.
|
||||
ExpireIn int `json:"expire_in"`
|
||||
// Maximum number of verification attempts per code.
|
||||
MaxRetries int `json:"max_retries"`
|
||||
}
|
||||
var config configType
|
||||
if err := json.Unmarshal(jsonconf, &config); err != nil {
|
||||
return errors.New("auth_code: failed to parse config: " + err.Error() + "(" + string(jsonconf) + ")")
|
||||
}
|
||||
|
||||
if config.ExpireIn <= 0 {
|
||||
return errors.New("auth_code: invalid expiration period")
|
||||
}
|
||||
|
||||
if config.CodeLength < 4 {
|
||||
return errors.New("auth_code: invalid code length")
|
||||
}
|
||||
|
||||
if config.MaxRetries < 1 {
|
||||
return errors.New("auth_code: invalid retries count")
|
||||
}
|
||||
|
||||
ca.name = name
|
||||
ca.codeLength = config.CodeLength
|
||||
ca.maxCodeValue = big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(ca.codeLength)), nil)
|
||||
ca.lifetime = time.Duration(config.ExpireIn) * time.Second
|
||||
ca.maxRetries = config.MaxRetries
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsInitialized returns true if the handler is initialized.
|
||||
func (ca *authenticator) IsInitialized() bool {
|
||||
return ca.name != ""
|
||||
}
|
||||
|
||||
// AddRecord is not supported, will produce an error.
|
||||
func (authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
return nil, types.ErrUnsupported
|
||||
}
|
||||
|
||||
// UpdateRecord is not supported, will produce an error.
|
||||
func (authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
return nil, types.ErrUnsupported
|
||||
}
|
||||
|
||||
// Authenticate checks validity of provided short code.
|
||||
// The secret is structured as <code>:<cred_method>:<cred_value>, "123456:email:alice@example.com".
|
||||
func (ca *authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth.Rec, []byte, error) {
|
||||
if !ctx.IsValid() {
|
||||
return nil, nil, types.ErrMalformed
|
||||
}
|
||||
parts := strings.SplitN(string(secret), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, nil, types.ErrMalformed
|
||||
}
|
||||
|
||||
code, cred := parts[0], parts[1]
|
||||
key := cacheKey(ctx.TenantID, cred)
|
||||
|
||||
value, err := store.PCache.Get(ctx.TenantID, key)
|
||||
if err != nil {
|
||||
if err == types.ErrNotFound {
|
||||
err = types.ErrFailed
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// code:count:uid
|
||||
parts = strings.Split(value, ":")
|
||||
if len(parts) != 3 {
|
||||
return nil, nil, types.ErrInternal
|
||||
}
|
||||
|
||||
count, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return nil, nil, types.ErrInternal
|
||||
}
|
||||
|
||||
if count >= ca.maxRetries {
|
||||
return nil, nil, types.ErrFailed
|
||||
}
|
||||
|
||||
if parts[0] != code {
|
||||
// Update count of attempts. If the update fails, the error is ignored.
|
||||
store.PCache.Upsert(ctx.TenantID, key, parts[0]+":"+strconv.Itoa(count+1)+":"+parts[2], false)
|
||||
return nil, nil, types.ErrFailed
|
||||
}
|
||||
|
||||
// Success. Remove no longer needed entry. The error is ignored here.
|
||||
if err = store.PCache.Delete(ctx.TenantID, key); err != nil {
|
||||
logs.Warn.Println("code_auth: error deleting key", key, err)
|
||||
}
|
||||
|
||||
return &auth.Rec{
|
||||
TenantID: ctx.TenantID,
|
||||
Uid: types.ParseUid(parts[2]),
|
||||
AuthLevel: auth.LevelNone,
|
||||
Lifetime: auth.Duration(ca.lifetime),
|
||||
Features: auth.FeatureNoLogin,
|
||||
State: types.StateUndefined,
|
||||
Credential: cred}, nil, nil
|
||||
}
|
||||
|
||||
// GenSecret generates a new code.
|
||||
func (ca *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
|
||||
if rec == nil || rec.TenantID.IsZero() {
|
||||
return nil, time.Time{}, types.ErrMalformed
|
||||
}
|
||||
// Run garbage collection.
|
||||
store.PCache.Expire(rec.TenantID, cachePrefix(rec.TenantID), time.Now().UTC().Add(-ca.lifetime))
|
||||
|
||||
// Generate random code.
|
||||
code, err := rand.Int(rand.Reader, ca.maxCodeValue)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, types.ErrInternal
|
||||
}
|
||||
|
||||
// Convert the code to fixed length string.
|
||||
resp := strconv.FormatInt(code.Int64(), 10)
|
||||
resp = strings.Repeat("0", ca.codeLength-len(resp)) + resp
|
||||
|
||||
if rec.Lifetime == 0 {
|
||||
rec.Lifetime = auth.Duration(ca.lifetime)
|
||||
} else if rec.Lifetime < 0 {
|
||||
return nil, time.Time{}, types.ErrExpired
|
||||
}
|
||||
|
||||
// Save "code:counter:uid" to the database. The key is code_<credential>.
|
||||
if err = store.PCache.Upsert(rec.TenantID, cacheKey(rec.TenantID, rec.Credential), resp+":0:"+rec.Uid.String(), true); err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
|
||||
expires := time.Now().Add(time.Duration(rec.Lifetime)).UTC().Round(time.Millisecond)
|
||||
|
||||
return []byte(resp), expires, nil
|
||||
}
|
||||
|
||||
// AsTag is not supported, will produce an empty string.
|
||||
func (authenticator) AsTag(token string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsUnique is not supported, will produce an error.
|
||||
func (authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, error) {
|
||||
return false, types.ErrUnsupported
|
||||
}
|
||||
|
||||
// DelRecords adds disabled user ID to a stop list.
|
||||
func (authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestrictedTags returns tag namespaces restricted by this authenticator (none for short code).
|
||||
func (authenticator) RestrictedTags() ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetResetParams returns authenticator parameters passed to password reset handler
|
||||
// (none for short code).
|
||||
func (authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Replace all occurrences of '%' with '/' to ensure SQL LIKE query works correctly.
|
||||
func sanitizeKey(key string) string {
|
||||
return strings.ReplaceAll(key, "%", "/")
|
||||
}
|
||||
|
||||
func cachePrefix(tenantID types.TenantID) string {
|
||||
return realName + "_" + strconv.FormatInt(int64(tenantID), 10) + "_"
|
||||
}
|
||||
|
||||
func cacheKey(tenantID types.TenantID, credential string) string {
|
||||
return sanitizeKey(cachePrefix(tenantID) + credential)
|
||||
}
|
||||
|
||||
const realName = "code"
|
||||
|
||||
// GetRealName returns the hardcoded name of the authenticator.
|
||||
func (authenticator) GetRealName() string {
|
||||
return realName
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.RegisterAuthScheme(realName, &authenticator{})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: auth/auth.go
|
||||
|
||||
// Package mock_auth is a generated GoMock package.
|
||||
package mock_auth
|
||||
|
||||
import (
|
||||
json "encoding/json"
|
||||
reflect "reflect"
|
||||
time "time"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
auth "github.com/tinode/chat/server/auth"
|
||||
types "github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
// MockAuthHandler is a mock of AuthHandler interface.
|
||||
type MockAuthHandler struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockAuthHandlerMockRecorder
|
||||
}
|
||||
|
||||
// MockAuthHandlerMockRecorder is the mock recorder for MockAuthHandler.
|
||||
type MockAuthHandlerMockRecorder struct {
|
||||
mock *MockAuthHandler
|
||||
}
|
||||
|
||||
// NewMockAuthHandler creates a new mock instance.
|
||||
func NewMockAuthHandler(ctrl *gomock.Controller) *MockAuthHandler {
|
||||
mock := &MockAuthHandler{ctrl: ctrl}
|
||||
mock.recorder = &MockAuthHandlerMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockAuthHandler) EXPECT() *MockAuthHandlerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// AddRecord mocks base method.
|
||||
func (m *MockAuthHandler) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AddRecord", ctx, rec, secret)
|
||||
ret0, _ := ret[0].(*auth.Rec)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// AddRecord indicates an expected call of AddRecord.
|
||||
func (mr *MockAuthHandlerMockRecorder) AddRecord(ctx, rec, secret interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddRecord", reflect.TypeOf((*MockAuthHandler)(nil).AddRecord), ctx, rec, secret)
|
||||
}
|
||||
|
||||
// AsTag mocks base method.
|
||||
func (m *MockAuthHandler) AsTag(token string) string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AsTag", token)
|
||||
ret0, _ := ret[0].(string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// AsTag indicates an expected call of AsTag.
|
||||
func (mr *MockAuthHandlerMockRecorder) AsTag(token interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AsTag", reflect.TypeOf((*MockAuthHandler)(nil).AsTag), token)
|
||||
}
|
||||
|
||||
// Authenticate mocks base method.
|
||||
func (m *MockAuthHandler) Authenticate(ctx auth.AuthContext, secret []byte) (*auth.Rec, []byte, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Authenticate", ctx, secret)
|
||||
ret0, _ := ret[0].(*auth.Rec)
|
||||
ret1, _ := ret[1].([]byte)
|
||||
ret2, _ := ret[2].(error)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// Authenticate indicates an expected call of Authenticate.
|
||||
func (mr *MockAuthHandlerMockRecorder) Authenticate(ctx, secret interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Authenticate", reflect.TypeOf((*MockAuthHandler)(nil).Authenticate), ctx, secret)
|
||||
}
|
||||
|
||||
// DelRecords mocks base method.
|
||||
func (m *MockAuthHandler) DelRecords(tenantID types.TenantID, uid types.Uid) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DelRecords", tenantID, uid)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DelRecords indicates an expected call of DelRecords.
|
||||
func (mr *MockAuthHandlerMockRecorder) DelRecords(tenantID, uid interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DelRecords", reflect.TypeOf((*MockAuthHandler)(nil).DelRecords), tenantID, uid)
|
||||
}
|
||||
|
||||
// GenSecret mocks base method.
|
||||
func (m *MockAuthHandler) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GenSecret", rec)
|
||||
ret0, _ := ret[0].([]byte)
|
||||
ret1, _ := ret[1].(time.Time)
|
||||
ret2, _ := ret[2].(error)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// GenSecret indicates an expected call of GenSecret.
|
||||
func (mr *MockAuthHandlerMockRecorder) GenSecret(rec interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenSecret", reflect.TypeOf((*MockAuthHandler)(nil).GenSecret), rec)
|
||||
}
|
||||
|
||||
// GetRealName mocks base method.
|
||||
func (m *MockAuthHandler) GetRealName() string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetRealName")
|
||||
ret0, _ := ret[0].(string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetRealName indicates an expected call of GetRealName.
|
||||
func (mr *MockAuthHandlerMockRecorder) GetRealName() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRealName", reflect.TypeOf((*MockAuthHandler)(nil).GetRealName))
|
||||
}
|
||||
|
||||
// GetResetParams mocks base method.
|
||||
func (m *MockAuthHandler) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetResetParams", tenantID, uid)
|
||||
ret0, _ := ret[0].(map[string]any)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetResetParams indicates an expected call of GetResetParams.
|
||||
func (mr *MockAuthHandlerMockRecorder) GetResetParams(tenantID, uid interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetResetParams", reflect.TypeOf((*MockAuthHandler)(nil).GetResetParams), tenantID, uid)
|
||||
}
|
||||
|
||||
// Init mocks base method.
|
||||
func (m *MockAuthHandler) Init(jsonconf json.RawMessage, name string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Init", jsonconf, name)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Init indicates an expected call of Init.
|
||||
func (mr *MockAuthHandlerMockRecorder) Init(jsonconf, name interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockAuthHandler)(nil).Init), jsonconf, name)
|
||||
}
|
||||
|
||||
// IsInitialized mocks base method.
|
||||
func (m *MockAuthHandler) IsInitialized() bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "IsInitialized")
|
||||
ret0, _ := ret[0].(bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// IsInitialized indicates an expected call of IsInitialized.
|
||||
func (mr *MockAuthHandlerMockRecorder) IsInitialized() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsInitialized", reflect.TypeOf((*MockAuthHandler)(nil).IsInitialized))
|
||||
}
|
||||
|
||||
// IsUnique mocks base method.
|
||||
func (m *MockAuthHandler) IsUnique(ctx auth.AuthContext, secret []byte) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "IsUnique", ctx, secret)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// IsUnique indicates an expected call of IsUnique.
|
||||
func (mr *MockAuthHandlerMockRecorder) IsUnique(ctx, secret interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsUnique", reflect.TypeOf((*MockAuthHandler)(nil).IsUnique), ctx, secret)
|
||||
}
|
||||
|
||||
// RestrictedTags mocks base method.
|
||||
func (m *MockAuthHandler) RestrictedTags() ([]string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "RestrictedTags")
|
||||
ret0, _ := ret[0].([]string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// RestrictedTags indicates an expected call of RestrictedTags.
|
||||
func (mr *MockAuthHandlerMockRecorder) RestrictedTags() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestrictedTags", reflect.TypeOf((*MockAuthHandler)(nil).RestrictedTags))
|
||||
}
|
||||
|
||||
// UpdateRecord mocks base method.
|
||||
func (m *MockAuthHandler) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateRecord", ctx, rec, secret)
|
||||
ret0, _ := ret[0].(*auth.Rec)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateRecord indicates an expected call of UpdateRecord.
|
||||
func (mr *MockAuthHandlerMockRecorder) UpdateRecord(ctx, rec, secret interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateRecord", reflect.TypeOf((*MockAuthHandler)(nil).UpdateRecord), ctx, rec, secret)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
# REST or JSON-RPC authenticator
|
||||
|
||||
This authenticator permits authentication of Tinode users and creation of Tinode accounts using a separate process as a
|
||||
source of truth. For instance, if accounts are managed by corporate LDAP, this service allows handling of Tinode
|
||||
authentication using the same LDAP service.
|
||||
|
||||
This authenticator calls a designated authentication service over HTTP(S) POST. A skeleton implementation of a server
|
||||
is provided for reference at [rest-auth](../../../rest-auth/). The requests may be handled either by a single endpoint
|
||||
or by separate per-request endpoints.
|
||||
|
||||
Request and response payloads are formatted as JSON. Some of the request or response fields are context-dependent and
|
||||
may be skipped.
|
||||
|
||||
<!-- TOC depthFrom:1 depthTo:6 withLinks:1 updateOnSave:1 orderedList:0 -->
|
||||
|
||||
- [REST or JSON-RPC authenticator](#rest-or-json-rpc-authenticator)
|
||||
- [Configuration](#configuration)
|
||||
- [Request](#request)
|
||||
- [Response](#response)
|
||||
- [Recognized error responses](#recognized-error-responses)
|
||||
- [The server must implement the following endpoints:](#the-server-must-implement-the-following-endpoints)
|
||||
- [`add` Add new authentication record](#add-add-new-authentication-record)
|
||||
- [Sample request](#sample-request)
|
||||
- [Sample response (rec values may change)](#sample-response-rec-values-may-change)
|
||||
- [`auth` Request for authentication](#auth-request-for-authentication)
|
||||
- [Sample request](#sample-request)
|
||||
- [Sample response when the account already exists (optional challenge included)](#sample-response-when-the-account-already-exists-optional-challenge-included)
|
||||
- [Sample response when the account needs to be created by Tinode](#sample-response-when-the-account-needs-to-be-created-by-tinode)
|
||||
- [`checkunique` Checks if provided authentication record is unique.](#checkunique-checks-if-provided-authentication-record-is-unique)
|
||||
- [Sample request](#sample-request)
|
||||
- [Sample response](#sample-response)
|
||||
- [`del` Requests to delete authentication record.](#del-requests-to-delete-authentication-record)
|
||||
- [Sample request](#sample-request)
|
||||
- [Sample response](#sample-response)
|
||||
- [`gen` Generate authentication secret.](#gen-generate-authentication-secret)
|
||||
- [Sample request](#sample-request)
|
||||
- [Sample response](#sample-response)
|
||||
- [`link` Requests server to link new account ID to authentication record.](#link-requests-server-to-link-new-account-id-to-authentication-record)
|
||||
- [Sample request](#sample-request)
|
||||
- [Sample response](#sample-response)
|
||||
- [`upd` Update authentication record.](#upd-update-authentication-record)
|
||||
- [Sample request](#sample-request)
|
||||
- [Sample response](#sample-response)
|
||||
- [`rtagns` Get a list of restricted tag namespaces.](#rtagns-get-a-list-of-restricted-tag-namespaces)
|
||||
- [Sample request](#sample-request)
|
||||
- [Sample response](#sample-response)
|
||||
|
||||
<!-- /TOC -->
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the following section to the `auth_config` in [tinode.conf](../../tinode.conf):
|
||||
|
||||
```js
|
||||
...
|
||||
"auth_config": {
|
||||
...
|
||||
"rest": {
|
||||
// ServerUrl is the URL of the authentication server to call. The URL must be absolute:
|
||||
// it must include the scheme, such as http or https, and the host name.
|
||||
"server_url": "http://127.0.0.1:5000/",
|
||||
// Authentication server is allowed to create new accounts.
|
||||
"allow_new_accounts": true,
|
||||
// Use separate endpoints, i.e. add request name to serverUrl path when making requests:
|
||||
// http://127.0.0.1:5000/add
|
||||
"use_separate_endpoints": true
|
||||
},
|
||||
...
|
||||
},
|
||||
```
|
||||
If you want to use your authenticator **instead** of stock `basic` (login-password) authentication,
|
||||
add logical renaming and disable `rest` at the original name:
|
||||
```js
|
||||
...
|
||||
"auth_config": {
|
||||
"logical_names": ["basic:rest", "rest:"],
|
||||
"rest": { ... },
|
||||
...
|
||||
},
|
||||
...
|
||||
```
|
||||
|
||||
## Request
|
||||
|
||||
```js
|
||||
{
|
||||
"endpoint": "auth", // string, one of the endpoints as described below, optional.
|
||||
"secret": "Ym9iOmJvYjEyMw==", // authentication secret as provided by the client,
|
||||
// base64-encoded bytes, optional.
|
||||
"addr": "2001:0db8:85a3:0000:0000:8a2e:0370:7334", // string, IPv4 or IPv6 address of
|
||||
// the client making the request, optional.
|
||||
"rec": { // authentication record, optional.
|
||||
{
|
||||
"uid": "LELEQHDWbgY", // user ID, int64 base64-encoded
|
||||
"authlvl": "auth", // authentication level
|
||||
"lifetime": "10000s", // lifetime of this record in seconds or as time.Duration string;
|
||||
// see https://golang.org/pkg/time/#Duration for format.
|
||||
"features": 1, // bitmap of features as integer or as a string of feature characters:
|
||||
// "validated" (V) or "no login" (L)".
|
||||
"tags": ["email:alice@example.com"], // Tags associated with this authentication record.
|
||||
"state": "ok", // optional account state.
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Response
|
||||
|
||||
```js
|
||||
{
|
||||
"err": "internal", // string, error message in case of an error.
|
||||
"rec": { // authentication record.
|
||||
... // the same as `request.rec`
|
||||
},
|
||||
"byteval": "Ym9iOmJvYjEyMw==", // array of bytes, optional
|
||||
"ts": "2018-12-04T15:17:02.627Z", // time stamp, optional
|
||||
"boolval": true, // boolean value, optional
|
||||
"strarr": ["abc", "def"], // array of strings, optoional
|
||||
"newacc": { // data to use for creating a new account.
|
||||
// Default access mode
|
||||
"auth": "JRWPS",
|
||||
"anon": "N",
|
||||
"public": {...}, // user's public data, see /docs/API.md#trusted-public-and-private-fields
|
||||
"trusted": {...}, // user's trusted data, see /docs/API.md#trusted-public-and-private-fields
|
||||
"private": {...} // user's private data, see /docs/API.md#trusted-public-and-private-fields
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Recognized error responses
|
||||
|
||||
The error is returned as json:
|
||||
|
||||
```json
|
||||
{ "err": "error-message" }
|
||||
```
|
||||
|
||||
See [here](../../store/types/types.go#L24) for an up to date list of supported error messages.
|
||||
|
||||
* "internal": database failure or other internal catch-all failure.
|
||||
* "malformed": request cannot be parsed or otherwise wrong.
|
||||
* "failed": authentication failed (wrong login or password, etc).
|
||||
* "duplicate value": duplicate credential, i.e. attempt to create a record with a non-unique login.
|
||||
* "unsupported": the operation is not supported.
|
||||
* "expired": the secret has expired.
|
||||
* "policy": policy violation, e.g. password too weak.
|
||||
* "credentials": credentials like email or captcha must be validated.
|
||||
* "not found": the object was not found.
|
||||
* "denied": the operation is not permitted.
|
||||
|
||||
## The server must implement the following endpoints:
|
||||
|
||||
### `add` Add new authentication record
|
||||
|
||||
This endpoint requests server to add a new authentication record. This endpoint is generally used for account creation.
|
||||
If accounts are managed externally, it's likely to be unused and should generally return an error `"unsupported"`.
|
||||
|
||||
#### Sample request
|
||||
```json
|
||||
{
|
||||
"endpoint": "add",
|
||||
"secret": "Ym9iOmJvYjEyMw==",
|
||||
"addr": "111.22.33.44",
|
||||
"rec": {
|
||||
"uid": "LELEQHDWbgY",
|
||||
"lifetime": "10000s",
|
||||
"features": 2,
|
||||
"tags": ["email:alice@example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Sample response (rec values may change)
|
||||
```json
|
||||
{
|
||||
"rec": {
|
||||
"uid": "LELEQHDWbgY",
|
||||
"authlvl": "auth",
|
||||
"lifetime": "5000s",
|
||||
"features": 1,
|
||||
"tags": ["email:alice@example.com", "uname:alice"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `auth` Request for authentication
|
||||
|
||||
Request to authenticate a user. Client (Tinode) provides a secret, authentication server responds with a user record.
|
||||
If this is a very first login and the server manages the accounts, the server may return `newacc` object which will
|
||||
be used by client (Tinode) to create the account. The server may optionally return a challenge as `byteval`.
|
||||
|
||||
#### Sample request
|
||||
```json
|
||||
{
|
||||
"endpoint": "auth",
|
||||
"secret": "Ym9iOmJvYjEyMw==",
|
||||
"addr": "111.22.33.44"
|
||||
}
|
||||
```
|
||||
|
||||
#### Sample response when the account already exists (optional challenge included)
|
||||
```json
|
||||
{
|
||||
"rec": {
|
||||
"uid": "LELEQHDWbgY",
|
||||
"authlvl": "auth",
|
||||
"state": "ok"
|
||||
},
|
||||
"byteval": "9X6m3tWeBEMlDxlcFAABAAEAbVs"
|
||||
}
|
||||
```
|
||||
|
||||
#### Sample response when the account needs to be created by Tinode
|
||||
```js
|
||||
{
|
||||
"rec": {
|
||||
"state": "suspended", // Or "ok".
|
||||
"authlvl": "auth",
|
||||
"lifetime": "5000s",
|
||||
"features": 1,
|
||||
"tags": ["email:alice@example.com", "uname:alice"]
|
||||
},
|
||||
"newacc": {
|
||||
"auth": "JRWPS",
|
||||
"anon": "N",
|
||||
"public": {/* see /docs/API.md#trusted-public-and-private-fields */},
|
||||
"trusted": {/* see /docs/API.md#trusted-public-and-private-fields */},
|
||||
"private": {/* see /docs/API.md#trusted-public-and-private-fields */}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `checkunique` Checks if provided authentication record is unique.
|
||||
|
||||
Request is used for account creation. If accounts are managed by the server, the server should respond with
|
||||
an error `"unsupported"`.
|
||||
|
||||
#### Sample request
|
||||
```json
|
||||
{
|
||||
"endpoint": "checkunique",
|
||||
"secret": "Ym9iOmJvYjEyMw==",
|
||||
"addr": "111.22.33.44"
|
||||
}
|
||||
```
|
||||
|
||||
#### Sample response
|
||||
```json
|
||||
{
|
||||
"boolval": true
|
||||
}
|
||||
```
|
||||
|
||||
### `del` Requests to delete authentication record.
|
||||
|
||||
If accounts are managed by the server, the server should respond with an error `"unsupported"`.
|
||||
|
||||
#### Sample request
|
||||
```json
|
||||
{
|
||||
"endpoint": "del",
|
||||
"rec": {
|
||||
"uid": "LELEQHDWbgY",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Sample response
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
|
||||
### `gen` Generate authentication secret.
|
||||
|
||||
If accounts are managed by the server, the server should respond with an error `"unsupported"`.
|
||||
|
||||
#### Sample request
|
||||
```json
|
||||
{
|
||||
"endpoint": "gen",
|
||||
"rec": {
|
||||
"uid": "LELEQHDWbgY",
|
||||
"authlvl": "auth",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Sample response
|
||||
```json
|
||||
{
|
||||
"byteval": "9X6m3tWeBEMlDxlcFAABAAEAbVs",
|
||||
"ts": "2018-12-04T15:17:02.627Z",
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### `link` Requests server to link new account ID to authentication record.
|
||||
|
||||
If server requested Tinode to create a new account, this endpoint is used to link the new Tinode user ID with the
|
||||
server's authentication record. If linking is successful, the server should respond with a non-empty json.
|
||||
|
||||
#### Sample request
|
||||
```json
|
||||
{
|
||||
"endpoint": "link",
|
||||
"secret": "Ym9iOmJvYjEyMw==",
|
||||
"rec": {
|
||||
"uid": "LELEQHDWbgY",
|
||||
"authlvl": "auth",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Sample response
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
|
||||
### `upd` Update authentication record.
|
||||
|
||||
If accounts are managed by the server, the server should respond with an error `"unsupported"`.
|
||||
|
||||
#### Sample request
|
||||
```json
|
||||
{
|
||||
"endpoint": "upd",
|
||||
"secret": "Ym9iOmJvYjEyMw==",
|
||||
"addr": "111.22.33.44",
|
||||
"rec": {
|
||||
"uid": "LELEQHDWbgY",
|
||||
"authlvl": "auth",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Sample response
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
### `rtagns` Get a list of restricted tag namespaces.
|
||||
|
||||
Server may enforce certain tag namespaces (tag prefixes) to be restricted, i.e. not editable by the user.
|
||||
These are also used in Tinode discovery mechanism (e.g. searching for users, contact sync). See
|
||||
[API docs](/docs/API.md#fnd-and-tags-finding-users-and-topics) for details.
|
||||
|
||||
The server may optionally provide a regular expression to validate search tokens before rewriting them as prefixed tags.
|
||||
I.e. if server allows only logins of 3-8 ASCII letters and numbers then the regexp could be `^[a-z0-9_]{3,8}$`
|
||||
which is base64-encoded as `XlthLXowLTlfXXszLDh9JA==`.
|
||||
|
||||
#### Sample request
|
||||
```json
|
||||
{
|
||||
"endpoint": "rtagns",
|
||||
}
|
||||
```
|
||||
|
||||
#### Sample response
|
||||
```json
|
||||
{
|
||||
"strarr": ["basic", "email", "tel"],
|
||||
"byteval": "XlthLXowLTlfXXszLDh9JA=="
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
// Package rest provides authentication by calling a separate process over REST API (technically JSON RPC, not REST).
|
||||
package rest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/auth"
|
||||
"github.com/tinode/chat/server/logs"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
// authenticator is the type to map authentication methods to.
|
||||
type authenticator struct {
|
||||
// Logical name of this authenticator
|
||||
name string
|
||||
// URL of the server
|
||||
serverUrl string
|
||||
// Authenticator may add new accounts to local database.
|
||||
allowNewAccounts bool
|
||||
// Use separate endpoints, i.e. add request name to serverUrl path when making requests.
|
||||
useSeparateEndpoints bool
|
||||
// Cache of restricted tag prefixes (namespaces).
|
||||
rTagNS []string
|
||||
// Optional regex pattern for checking tokens.
|
||||
reToken *regexp.Regexp
|
||||
}
|
||||
|
||||
// Request to the server.
|
||||
type request struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Name string `json:"name"`
|
||||
TenantID types.TenantID `json:"tenant_id,omitempty"`
|
||||
Record *auth.Rec `json:"rec,omitempty"`
|
||||
Secret []byte `json:"secret,omitempty"`
|
||||
RemoteAddr string `json:"addr,omitempty"`
|
||||
}
|
||||
|
||||
// User initialization data when creating a new user.
|
||||
type newAccount struct {
|
||||
// Default access mode
|
||||
Auth string `json:"auth,omitempty"`
|
||||
Anon string `json:"anon,omitempty"`
|
||||
// User's Public data
|
||||
Public any `json:"public,omitempty"`
|
||||
// User's Trusted data
|
||||
Trusted any `json:"trusted,omitempty"`
|
||||
// Per-subscription private data
|
||||
Private any `json:"private,omitempty"`
|
||||
}
|
||||
|
||||
// Response from the server.
|
||||
type response struct {
|
||||
// Error message in case of an error.
|
||||
Err string `json:"err,omitempty"`
|
||||
// Optional auth record
|
||||
Record *auth.Rec `json:"rec,omitempty"`
|
||||
// Optional byte slice
|
||||
ByteVal []byte `json:"byteval,omitempty"`
|
||||
// Optional time value
|
||||
TimeVal time.Time `json:"ts,omitempty"`
|
||||
// Boolean value
|
||||
BoolVal bool `json:"boolval,omitempty"`
|
||||
// String slice value
|
||||
StrSliceVal []string `json:"strarr,omitempty"`
|
||||
// Account creation data
|
||||
NewAcc *newAccount `json:"newacc,omitempty"`
|
||||
}
|
||||
|
||||
// Init initializes the handler.
|
||||
func (a *authenticator) Init(jsonconf json.RawMessage, name string) error {
|
||||
if name == "" {
|
||||
return errors.New("auth_rest: authenticator name cannot be blank")
|
||||
}
|
||||
|
||||
if a.name != "" {
|
||||
return errors.New("auth_rest: already initialized as " + a.name + "; " + name)
|
||||
}
|
||||
|
||||
type configType struct {
|
||||
// ServerUrl is the URL of the server to call.
|
||||
ServerUrl string `json:"server_url"`
|
||||
// Server may create new accounts.
|
||||
AllowNewAccounts bool `json:"allow_new_accounts"`
|
||||
// Use separate endpoints, i.e. add request name to serverUrl path when making requests.
|
||||
UseSeparateEndpoints bool `json:"use_separate_endpoints"`
|
||||
}
|
||||
|
||||
var config configType
|
||||
err := json.Unmarshal(jsonconf, &config)
|
||||
if err != nil {
|
||||
return errors.New("auth_rest: failed to parse config: " + err.Error() + "(" + string(jsonconf) + ")")
|
||||
}
|
||||
|
||||
serverUrl, err := url.Parse(config.ServerUrl)
|
||||
if err != nil || !serverUrl.IsAbs() {
|
||||
return errors.New("auth_rest: invalid server_url '" + string(jsonconf) + "'")
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(serverUrl.Path, "/") {
|
||||
serverUrl.Path += "/"
|
||||
}
|
||||
|
||||
a.name = name
|
||||
a.serverUrl = serverUrl.String()
|
||||
a.allowNewAccounts = config.AllowNewAccounts
|
||||
a.useSeparateEndpoints = config.UseSeparateEndpoints
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsInitialized returns true if the handler is initialized.
|
||||
func (a *authenticator) IsInitialized() bool {
|
||||
return a.name != ""
|
||||
}
|
||||
|
||||
// Execute HTTP POST to the server at the specified endpoint and with the provided payload.
|
||||
func (a *authenticator) callEndpoint(endpoint string, ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*response, error) {
|
||||
// Convert payload to json.
|
||||
req := &request{Endpoint: endpoint, Name: a.name, TenantID: ctx.TenantID, Record: rec,
|
||||
Secret: secret, RemoteAddr: ctx.RemoteAddr}
|
||||
content, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
urlToCall := a.serverUrl
|
||||
if a.useSeparateEndpoints {
|
||||
epUrl, _ := url.Parse(a.serverUrl)
|
||||
epUrl.Path += endpoint
|
||||
urlToCall = epUrl.String()
|
||||
}
|
||||
|
||||
// Send payload to server using default HTTP client.
|
||||
post, err := http.Post(urlToCall, "application/json", bytes.NewBuffer(content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer post.Body.Close()
|
||||
|
||||
// Check HTTP status response. Must be 2xx.
|
||||
if post.StatusCode < http.StatusOK || post.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, errors.New("unexpected HTTP response " + post.Status)
|
||||
}
|
||||
|
||||
// Read response.
|
||||
body, err := io.ReadAll(post.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse response.
|
||||
var resp response
|
||||
err = json.Unmarshal(body, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.Err != "" {
|
||||
return nil, types.StoreError(resp.Err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// AddRecord adds persistent authentication record to the database.
|
||||
// Returns: updated auth record, error
|
||||
func (a *authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
if err := auth.BindRecord(ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.callEndpoint("add", ctx, rec, secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Record == nil || resp.Record.TenantID != ctx.TenantID {
|
||||
return nil, types.ErrFailed
|
||||
}
|
||||
if err = auth.BindRecord(ctx, resp.Record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Record, nil
|
||||
}
|
||||
|
||||
// UpdateRecord updates existing record with new credentials.
|
||||
func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
if err := auth.BindRecord(ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err := a.callEndpoint("upd", ctx, rec, secret)
|
||||
return rec, err
|
||||
}
|
||||
|
||||
// Authenticate: get user record by provided secret
|
||||
func (a *authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth.Rec, []byte, error) {
|
||||
if !ctx.IsValid() {
|
||||
return nil, nil, types.ErrMalformed
|
||||
}
|
||||
resp, err := a.callEndpoint("auth", ctx, nil, secret)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Auth record not found.
|
||||
if resp.Record == nil {
|
||||
logs.Warn.Println("rest_auth: invalid response: missing Record")
|
||||
return nil, nil, types.ErrInternal
|
||||
}
|
||||
if resp.Record.TenantID != ctx.TenantID {
|
||||
return nil, nil, types.ErrFailed
|
||||
}
|
||||
if err = auth.BindRecord(ctx, resp.Record); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Check if server provided a user ID. If not, create a new account in the local database.
|
||||
if resp.Record.Uid.IsZero() && a.allowNewAccounts {
|
||||
if resp.NewAcc == nil {
|
||||
return nil, nil, types.ErrNotFound
|
||||
}
|
||||
|
||||
// Create account, get UID, report UID back to the server.
|
||||
|
||||
user := types.User{
|
||||
TenantID: ctx.TenantID,
|
||||
State: resp.Record.State,
|
||||
Public: resp.NewAcc.Public,
|
||||
Trusted: resp.NewAcc.Trusted,
|
||||
Tags: resp.Record.Tags,
|
||||
}
|
||||
user.Access.Auth.UnmarshalText([]byte(resp.NewAcc.Auth))
|
||||
user.Access.Anon.UnmarshalText([]byte(resp.NewAcc.Anon))
|
||||
_, err = store.Users.Create(ctx.TenantID, &user, resp.NewAcc.Private)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Report the new UID to the server.
|
||||
resp.Record.Uid = user.Uid()
|
||||
_, err = a.callEndpoint("link", ctx, resp.Record, secret)
|
||||
if err != nil {
|
||||
store.Users.Delete(ctx.TenantID, resp.Record.Uid, true)
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return resp.Record, resp.ByteVal, nil
|
||||
}
|
||||
|
||||
// AsTag converts search token into prefixed tag or an empty string if it
|
||||
// cannot be represented as a prefixed tag.
|
||||
func (a *authenticator) AsTag(token string) string {
|
||||
if len(a.rTagNS) > 0 {
|
||||
if a.reToken != nil && !a.reToken.MatchString(token) {
|
||||
return ""
|
||||
}
|
||||
// No validation or passed validation.
|
||||
return a.rTagNS[0] + ":" + token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsUnique verifies if the provided secret can be considered unique by the auth
|
||||
// scheme as well as policy compliance. E.g. if login is unique and not too short/long.
|
||||
func (a *authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, error) {
|
||||
if !ctx.IsValid() {
|
||||
return false, types.ErrMalformed
|
||||
}
|
||||
resp, err := a.callEndpoint("checkunique", ctx, nil, secret)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return resp.BoolVal, err
|
||||
}
|
||||
|
||||
// GenSecret generates a new secret, if appropriate.
|
||||
func (a *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
|
||||
if rec == nil || rec.TenantID.IsZero() {
|
||||
return nil, time.Time{}, types.ErrMalformed
|
||||
}
|
||||
resp, err := a.callEndpoint("gen", auth.AuthContext{TenantID: rec.TenantID}, rec, nil)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
|
||||
return resp.ByteVal, resp.TimeVal, err
|
||||
}
|
||||
|
||||
// DelRecords deletes all authentication records for the given user.
|
||||
func (a *authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
|
||||
logs.Info.Println("DelRecords, initialized=", a.name != "")
|
||||
ctx := auth.AuthContext{TenantID: tenantID}
|
||||
_, err := a.callEndpoint("del", ctx, &auth.Rec{TenantID: tenantID, Uid: uid}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// RestrictedTags returns tag namespaces (prefixes, such as prefix:login) restricted by the server.
|
||||
func (a *authenticator) RestrictedTags() ([]string, error) {
|
||||
if a.rTagNS != nil {
|
||||
// Using cached prefixes.
|
||||
ns := make([]string, len(a.rTagNS))
|
||||
// Returning a copy to prevent accidental modification of server-provided tags.
|
||||
copy(ns, a.rTagNS)
|
||||
return ns, nil
|
||||
}
|
||||
|
||||
// First time use, fetch prefixes from the server.
|
||||
resp, err := a.callEndpoint("rtagns", auth.AuthContext{}, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Save valid result to cache.
|
||||
a.rTagNS = resp.StrSliceVal
|
||||
if len(resp.ByteVal) > 0 {
|
||||
a.reToken, err = regexp.Compile(string(resp.ByteVal))
|
||||
if err != nil {
|
||||
logs.Warn.Println("rest_auth: invalid token regexp", string(resp.ByteVal))
|
||||
}
|
||||
}
|
||||
return resp.StrSliceVal, nil
|
||||
}
|
||||
|
||||
// GetResetParams returns authenticator parameters passed to password reset handler
|
||||
// (none for rest).
|
||||
func (authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
|
||||
// TODO: route request to the server.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
const realName = "rest"
|
||||
|
||||
// GetRealName returns the hardcoded name of the authenticator.
|
||||
func (authenticator) GetRealName() string {
|
||||
return realName
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.RegisterAuthScheme(realName, &authenticator{})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// Package token implements authentication by HMAC-signed security token.
|
||||
package token
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/auth"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
// authenticator is a singleton instance of the authenticator.
|
||||
type authenticator struct {
|
||||
name string
|
||||
hmacSalt []byte
|
||||
lifetime time.Duration
|
||||
serialNumber int
|
||||
}
|
||||
|
||||
const tokenVersion = uint8(2)
|
||||
|
||||
// tokenLayout defines positioning of various bytes in token v2.
|
||||
// [1:version][8:tenant][8:UID][4:expires][2:authLevel][2:serial][2:features][32:signature] = 59 bytes.
|
||||
type tokenLayout struct {
|
||||
// Token format version.
|
||||
Version uint8
|
||||
// Tenant ID.
|
||||
TenantID uint64
|
||||
// User ID.
|
||||
Uid uint64
|
||||
// Token expiration time.
|
||||
Expires uint32
|
||||
// User's authentication level.
|
||||
AuthLevel uint16
|
||||
// Serial number - to invalidate all tokens if needed.
|
||||
SerialNumber uint16
|
||||
// Bitmap with feature bits.
|
||||
Features uint16
|
||||
}
|
||||
|
||||
// Init initializes the authenticator: parses the config and sets salt, serial number and lifetime.
|
||||
func (ta *authenticator) Init(jsonconf json.RawMessage, name string) error {
|
||||
if name == "" {
|
||||
return errors.New("auth_token: authenticator name cannot be blank")
|
||||
}
|
||||
|
||||
if ta.name != "" {
|
||||
return errors.New("auth_token: already initialized as " + ta.name + "; " + name)
|
||||
}
|
||||
|
||||
type configType struct {
|
||||
// Key for signing tokens
|
||||
Key []byte `json:"key"`
|
||||
// Datatabase or other serial number, to invalidate all issued tokens at once.
|
||||
SerialNum int `json:"serial_num"`
|
||||
// Token expiration time
|
||||
ExpireIn int `json:"expire_in"`
|
||||
}
|
||||
var config configType
|
||||
if err := json.Unmarshal(jsonconf, &config); err != nil {
|
||||
return errors.New("auth_token: failed to parse config: " + err.Error() + "(" + string(jsonconf) + ")")
|
||||
}
|
||||
|
||||
if len(config.Key) < sha256.Size {
|
||||
return errors.New("auth_token: the key is missing or too short")
|
||||
}
|
||||
if config.ExpireIn <= 0 {
|
||||
return errors.New("auth_token: invalid expiration value")
|
||||
}
|
||||
|
||||
ta.name = name
|
||||
ta.hmacSalt = config.Key
|
||||
ta.lifetime = time.Duration(config.ExpireIn) * time.Second
|
||||
ta.serialNumber = config.SerialNum
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsInitialized returns true if the handler is initialized.
|
||||
func (ta *authenticator) IsInitialized() bool {
|
||||
return ta.name != ""
|
||||
}
|
||||
|
||||
// AddRecord is not supported, will produce an error.
|
||||
func (authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
return nil, types.ErrUnsupported
|
||||
}
|
||||
|
||||
// UpdateRecord is not supported, will produce an error.
|
||||
func (authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
return nil, types.ErrUnsupported
|
||||
}
|
||||
|
||||
// Authenticate checks validity of provided token.
|
||||
func (ta *authenticator) Authenticate(ctx auth.AuthContext, token []byte) (*auth.Rec, []byte, error) {
|
||||
var tl tokenLayout
|
||||
dataSize := binary.Size(&tl)
|
||||
if len(token) != dataSize+sha256.Size {
|
||||
return nil, nil, types.ErrMalformed
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(token)
|
||||
err := binary.Read(buf, binary.LittleEndian, &tl)
|
||||
if err != nil {
|
||||
return nil, nil, types.ErrMalformed
|
||||
}
|
||||
if tl.Version != tokenVersion || tl.TenantID == 0 {
|
||||
return nil, nil, types.ErrFailed
|
||||
}
|
||||
if ctx.IsValid() && types.TenantID(tl.TenantID) != ctx.TenantID {
|
||||
return nil, nil, types.ErrFailed
|
||||
}
|
||||
|
||||
hbuf := new(bytes.Buffer)
|
||||
binary.Write(hbuf, binary.LittleEndian, &tl)
|
||||
|
||||
// Check signature.
|
||||
hasher := hmac.New(sha256.New, ta.hmacSalt)
|
||||
hasher.Write(hbuf.Bytes())
|
||||
if !hmac.Equal(token[dataSize:dataSize+sha256.Size], hasher.Sum(nil)) {
|
||||
return nil, nil, types.ErrFailed
|
||||
}
|
||||
|
||||
// Check authentication level for validity.
|
||||
if auth.Level(tl.AuthLevel) > auth.LevelRoot {
|
||||
return nil, nil, types.ErrMalformed
|
||||
}
|
||||
|
||||
// Check serial number.
|
||||
if int(tl.SerialNumber) != ta.serialNumber {
|
||||
return nil, nil, types.ErrFailed
|
||||
}
|
||||
|
||||
// Check token expiration time.
|
||||
expires := time.Unix(int64(tl.Expires), 0).UTC()
|
||||
if expires.Before(time.Now().Add(1 * time.Second)) {
|
||||
return nil, nil, types.ErrExpired
|
||||
}
|
||||
|
||||
return &auth.Rec{
|
||||
TenantID: types.TenantID(tl.TenantID),
|
||||
Uid: types.Uid(tl.Uid),
|
||||
AuthLevel: auth.Level(tl.AuthLevel),
|
||||
Lifetime: auth.Duration(time.Until(expires)),
|
||||
Features: auth.Feature(tl.Features),
|
||||
State: types.StateUndefined}, nil, nil
|
||||
}
|
||||
|
||||
// GenSecret generates a new token.
|
||||
func (ta *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
|
||||
if rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
|
||||
return nil, time.Time{}, types.ErrMalformed
|
||||
}
|
||||
|
||||
if rec.Lifetime == 0 {
|
||||
rec.Lifetime = auth.Duration(ta.lifetime)
|
||||
} else if rec.Lifetime < 0 {
|
||||
return nil, time.Time{}, types.ErrExpired
|
||||
}
|
||||
expires := time.Now().Add(time.Duration(rec.Lifetime)).UTC().Round(time.Millisecond)
|
||||
|
||||
tl := tokenLayout{
|
||||
Version: tokenVersion,
|
||||
TenantID: uint64(rec.TenantID),
|
||||
Uid: uint64(rec.Uid),
|
||||
Expires: uint32(expires.Unix()),
|
||||
AuthLevel: uint16(rec.AuthLevel),
|
||||
SerialNumber: uint16(ta.serialNumber),
|
||||
Features: uint16(rec.Features),
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
binary.Write(buf, binary.LittleEndian, &tl)
|
||||
hasher := hmac.New(sha256.New, ta.hmacSalt)
|
||||
hasher.Write(buf.Bytes())
|
||||
binary.Write(buf, binary.LittleEndian, hasher.Sum(nil))
|
||||
|
||||
return buf.Bytes(), expires, nil
|
||||
}
|
||||
|
||||
// AsTag is not supported, will produce an empty string.
|
||||
func (authenticator) AsTag(token string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsUnique is not supported, will produce an error.
|
||||
func (authenticator) IsUnique(ctx auth.AuthContext, token []byte) (bool, error) {
|
||||
return false, types.ErrUnsupported
|
||||
}
|
||||
|
||||
// DelRecords adds disabled user ID to a stop list.
|
||||
func (authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestrictedTags returns tag namespaces restricted by this authenticator (none for token).
|
||||
func (authenticator) RestrictedTags() ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetResetParams returns authenticator parameters passed to password reset handler
|
||||
// (none for token).
|
||||
func (authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
const realName = "token"
|
||||
|
||||
// GetRealName returns the hardcoded name of the authenticator.
|
||||
func (authenticator) GetRealName() string {
|
||||
return realName
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.RegisterAuthScheme(realName, &authenticator{})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/auth"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func newTestAuthenticator(t *testing.T) *authenticator {
|
||||
t.Helper()
|
||||
conf, err := json.Marshal(map[string]any{
|
||||
"key": []byte("0123456789abcdef0123456789abcdef"),
|
||||
"serial_num": 3,
|
||||
"expire_in": 3600,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ta := &authenticator{}
|
||||
if err = ta.Init(conf, "token-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ta
|
||||
}
|
||||
|
||||
func TestTokenV2TenantRoundTrip(t *testing.T) {
|
||||
ta := newTestAuthenticator(t)
|
||||
rec := &auth.Rec{
|
||||
TenantID: 7,
|
||||
Uid: 42,
|
||||
AuthLevel: auth.LevelAuth,
|
||||
Lifetime: auth.Duration(time.Hour),
|
||||
Features: auth.FeatureValidated,
|
||||
}
|
||||
token, _, err := ta.GenSecret(rec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := len(token), 59; got != want {
|
||||
t.Fatalf("token length: got %d, want %d", got, want)
|
||||
}
|
||||
|
||||
got, challenge, err := ta.Authenticate(auth.AuthContext{TenantID: 7}, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if challenge != nil {
|
||||
t.Fatal("unexpected challenge")
|
||||
}
|
||||
if got.TenantID != rec.TenantID || got.Uid != rec.Uid || got.AuthLevel != rec.AuthLevel || got.Features != rec.Features {
|
||||
t.Fatalf("round trip mismatch: got %+v, want %+v", got, rec)
|
||||
}
|
||||
|
||||
// HTTP authentication may recover the tenant from the signed token.
|
||||
got, _, err = ta.Authenticate(auth.AuthContext{}, token)
|
||||
if err != nil || got.TenantID != rec.TenantID {
|
||||
t.Fatalf("tenant recovery failed: rec=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenV2RejectsWrongOrTamperedTenant(t *testing.T) {
|
||||
ta := newTestAuthenticator(t)
|
||||
token, _, err := ta.GenSecret(&auth.Rec{
|
||||
TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, err = ta.Authenticate(auth.AuthContext{TenantID: 8}, token); err != types.ErrFailed {
|
||||
t.Fatalf("wrong tenant: got %v, want %v", err, types.ErrFailed)
|
||||
}
|
||||
|
||||
tampered := append([]byte(nil), token...)
|
||||
tampered[1] ^= 1 // First byte of tenant_id in the little-endian payload.
|
||||
if _, _, err = ta.Authenticate(auth.AuthContext{}, tampered); err != types.ErrFailed {
|
||||
t.Fatalf("tampered tenant: got %v, want %v", err, types.ErrFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenV2RejectsLegacyAndMissingIdentity(t *testing.T) {
|
||||
ta := newTestAuthenticator(t)
|
||||
if _, _, err := ta.Authenticate(auth.AuthContext{}, make([]byte, 50)); err != types.ErrMalformed {
|
||||
t.Fatalf("legacy token: got %v, want %v", err, types.ErrMalformed)
|
||||
}
|
||||
if _, _, err := ta.GenSecret(&auth.Rec{Uid: 42, AuthLevel: auth.LevelAuth}); err != types.ErrMalformed {
|
||||
t.Fatalf("missing tenant: got %v, want %v", err, types.ErrMalformed)
|
||||
}
|
||||
if _, _, err := ta.GenSecret(&auth.Rec{TenantID: 7, AuthLevel: auth.LevelAuth}); err != types.ErrMalformed {
|
||||
t.Fatalf("missing uid: got %v, want %v", err, types.ErrMalformed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user