多租户更改

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
+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{})
}