多租户更改

This commit is contained in:
richjohnrambo
2026-08-11 15:59:57 +08:00
parent 6c800de67d
commit c2076721fc
453 changed files with 102349 additions and 47 deletions
+13 -7
View File
@@ -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
}
+307
View File
@@ -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
}
+25
View File
@@ -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)
}
}
+345
View File
@@ -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{})
}
+55
View File
@@ -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)
}
}
+227
View File
@@ -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{})
}
+215
View File
@@ -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)
}
+366
View File
@@ -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=="
}
```
+349
View File
@@ -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{})
}
+221
View File
@@ -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{})
}
+95
View File
@@ -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)
}
}