mirror of
https://github.com/tinode/chat.git
synced 2026-05-07 20:12:42 +00:00
213 lines
6.0 KiB
Go
213 lines
6.0 KiB
Go
// 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(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {
|
|
return nil, types.ErrUnsupported
|
|
}
|
|
|
|
// UpdateRecord is not supported, will produce an error.
|
|
func (authenticator) UpdateRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*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(secret []byte, remoteAddr string) (*auth.Rec, []byte, error) {
|
|
parts := strings.SplitN(string(secret), ":", 2)
|
|
if len(parts) != 2 {
|
|
return nil, nil, types.ErrMalformed
|
|
}
|
|
|
|
code, cred := parts[0], parts[1]
|
|
key := sanitizeKey(realName + "_" + cred)
|
|
|
|
value, err := store.PCache.Get(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(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(key); err != nil {
|
|
logs.Warn.Println("code_auth: error deleting key", key, err)
|
|
}
|
|
|
|
return &auth.Rec{
|
|
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) {
|
|
// Run garbage collection.
|
|
store.PCache.Expire(realName+"_", 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(sanitizeKey(realName+"_"+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(secret []byte, remoteAddr string) (bool, error) {
|
|
return false, types.ErrUnsupported
|
|
}
|
|
|
|
// DelRecords adds disabled user ID to a stop list.
|
|
func (authenticator) DelRecords(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(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, "%", "/")
|
|
}
|
|
|
|
const realName = "code"
|
|
|
|
// GetRealName returns the hardcoded name of the authenticator.
|
|
func (authenticator) GetRealName() string {
|
|
return realName
|
|
}
|
|
|
|
func init() {
|
|
store.RegisterAuthScheme(realName, &authenticator{})
|
|
}
|