mirror of
https://github.com/balzack/databag.git
synced 2025-02-11 19:19:16 +00:00
adding mfa to admin login
This commit is contained in:
parent
53bfc32d4f
commit
51306e92c4
149
doc/api.oa3
149
doc/api.oa3
@ -87,6 +87,139 @@ paths:
|
|||||||
'500':
|
'500':
|
||||||
description: internal server error
|
description: internal server error
|
||||||
|
|
||||||
|
/admin/access:
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- admin
|
||||||
|
description: Acquire new session token for admin endpoints
|
||||||
|
operationId: set-admin-session
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: query
|
||||||
|
description: access token
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: code
|
||||||
|
in: query
|
||||||
|
description: totp code
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: generated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
'401':
|
||||||
|
description: invalid token
|
||||||
|
'405':
|
||||||
|
description: totp code required but not set
|
||||||
|
'429':
|
||||||
|
description: temporarily locked due to too many failures
|
||||||
|
'500':
|
||||||
|
description: internal server error
|
||||||
|
|
||||||
|
/admin/mfauth:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- admin
|
||||||
|
description: check if multi-factor authentication enabled
|
||||||
|
operationId: get-admin-mfa
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: query
|
||||||
|
description: session token
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
'401':
|
||||||
|
description: permission denied
|
||||||
|
'500':
|
||||||
|
description: internal server error
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- admin
|
||||||
|
description: Enable multi-factor authentication
|
||||||
|
operationId: add-admin-mfa
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: query
|
||||||
|
description: session token
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
'401':
|
||||||
|
description: permission denied
|
||||||
|
'500':
|
||||||
|
description: internal server error
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- admin
|
||||||
|
description: Confirm multi-factor authentication
|
||||||
|
operationId: confirm-admin-mfa
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: query
|
||||||
|
description: session token
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: code
|
||||||
|
in: query
|
||||||
|
description: totp code generated from secret
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: success
|
||||||
|
'401':
|
||||||
|
description: permission denied
|
||||||
|
'403':
|
||||||
|
description: totp code not correct
|
||||||
|
'405':
|
||||||
|
description: totp code required but not set
|
||||||
|
'429':
|
||||||
|
description: temporarily locked due to too many failures
|
||||||
|
'500':
|
||||||
|
description: internal server error
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- admin
|
||||||
|
description: Disable multi-factor authentication
|
||||||
|
operationId: remove-admin-mfa
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: query
|
||||||
|
description: session token
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: success
|
||||||
|
'401':
|
||||||
|
description: permission denied
|
||||||
|
'500':
|
||||||
|
description: internal server error
|
||||||
|
|
||||||
/admin/config:
|
/admin/config:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -96,7 +229,7 @@ paths:
|
|||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: token
|
||||||
in: query
|
in: query
|
||||||
description: token for admin access
|
description: session token for admin access
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
@ -119,7 +252,7 @@ paths:
|
|||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: token
|
||||||
in: query
|
in: query
|
||||||
description: token for admin access
|
description: session token for admin access
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
@ -146,7 +279,7 @@ paths:
|
|||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: token
|
||||||
in: query
|
in: query
|
||||||
description: token for admin access
|
description: session token for admin access
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
@ -173,7 +306,7 @@ paths:
|
|||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: token
|
||||||
in: query
|
in: query
|
||||||
description: token for admin access
|
description: session token for admin access
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
@ -204,7 +337,7 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
- name: token
|
- name: token
|
||||||
in: query
|
in: query
|
||||||
description: token for admin access
|
description: session token for admin access
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
@ -238,7 +371,7 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
- name: token
|
- name: token
|
||||||
in: query
|
in: query
|
||||||
description: token for admin access
|
description: session token for admin access
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
@ -261,7 +394,7 @@ paths:
|
|||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: token
|
||||||
in: query
|
in: query
|
||||||
description: token for admin access
|
description: session token for admin access
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
@ -298,7 +431,7 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
- name: token
|
- name: token
|
||||||
in: query
|
in: query
|
||||||
description: token for admin access
|
description: session token for admin access
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
73
net/server/internal/api_addAdminMFAuth.go
Normal file
73
net/server/internal/api_addAdminMFAuth.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
package databag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"image/png"
|
||||||
|
"github.com/pquerna/otp"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
"databag/internal/store"
|
||||||
|
"encoding/base64"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
//AddAdminMFAuth enables multi-factor auth on the given account
|
||||||
|
func AddAdminMFAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
// validate login
|
||||||
|
if code, err := ParamSessionToken(r); err != nil {
|
||||||
|
ErrResponse(w, code, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := totp.Generate(totp.GenerateOpts{
|
||||||
|
Issuer: APPMFAIssuer,
|
||||||
|
AccountName: "admin",
|
||||||
|
Digits: otp.DigitsSix,
|
||||||
|
Algorithm: otp.AlgorithmSHA256,
|
||||||
|
})
|
||||||
|
|
||||||
|
err = store.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
|
||||||
|
// upsert mfa enabled
|
||||||
|
if res := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "config_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"bool_value"}),
|
||||||
|
}).Create(&store.Config{ConfigID: CNFMFAEnabled, BoolValue: false}).Error; res != nil {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsert mfa confirmed
|
||||||
|
if res := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "config_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"bool_value"}),
|
||||||
|
}).Create(&store.Config{ConfigID: CNFMFAConfirmed, BoolValue: true}).Error; res != nil {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsert mfa secret
|
||||||
|
if res := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "config_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"str_value"}),
|
||||||
|
}).Create(&store.Config{ConfigID: CNFMFASecret, StrValue: key.Secret()}).Error; res != nil {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ErrResponse(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
img, err := key.Image(200, 200)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
png.Encode(&buf, img)
|
||||||
|
enc := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||||
|
|
||||||
|
WriteResponse(w, MFASecret{ Image: "data:image/png;base64," + enc, Text: key.Secret() })
|
||||||
|
}
|
@ -11,7 +11,7 @@ import (
|
|||||||
//AddNodeAccount generate a new token to be used for account creation
|
//AddNodeAccount generate a new token to be used for account creation
|
||||||
func AddNodeAccount(w http.ResponseWriter, r *http.Request) {
|
func AddNodeAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
if code, err := ParamAdminToken(r); err != nil {
|
if code, err := ParamSessionToken(r); err != nil {
|
||||||
ErrResponse(w, code, err)
|
ErrResponse(w, code, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,7 @@ func AddNodeAccountAccess(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if code, err := ParamAdminToken(r); err != nil {
|
if code, err := ParamSessionToken(r); err != nil {
|
||||||
ErrResponse(w, code, err)
|
ErrResponse(w, code, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
20
net/server/internal/api_getAdminMFAuth.go
Normal file
20
net/server/internal/api_getAdminMFAuth.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package databag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//GetAdminMFAuth checks if mfa enabled for admin
|
||||||
|
func GetAdminMFAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
// validate login
|
||||||
|
if code, err := ParamSessionToken(r); err != nil {
|
||||||
|
ErrResponse(w, code, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled := getBoolConfigValue(CNFMFAEnabled, false);
|
||||||
|
confirmed := getBoolConfigValue(CNFMFAConfirmed, false);
|
||||||
|
|
||||||
|
WriteResponse(w, enabled && confirmed)
|
||||||
|
}
|
@ -23,7 +23,7 @@ func GetNodeAccountImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if code, err := ParamAdminToken(r); err != nil {
|
if code, err := ParamSessionToken(r); err != nil {
|
||||||
ErrResponse(w, code, err)
|
ErrResponse(w, code, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,7 @@ import (
|
|||||||
//GetNodeAccounts retrieves profiles of hosted accounts for the admin
|
//GetNodeAccounts retrieves profiles of hosted accounts for the admin
|
||||||
func GetNodeAccounts(w http.ResponseWriter, r *http.Request) {
|
func GetNodeAccounts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
if code, err := ParamAdminToken(r); err != nil {
|
if code, err := ParamSessionToken(r); err != nil {
|
||||||
ErrResponse(w, code, err)
|
ErrResponse(w, code, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,7 @@ import (
|
|||||||
func GetNodeConfig(w http.ResponseWriter, r *http.Request) {
|
func GetNodeConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// validate login
|
// validate login
|
||||||
if code, err := ParamAdminToken(r); err != nil {
|
if code, err := ParamSessionToken(r); err != nil {
|
||||||
ErrResponse(w, code, err)
|
ErrResponse(w, code, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
40
net/server/internal/api_removeAdminMFAuth.go
Normal file
40
net/server/internal/api_removeAdminMFAuth.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package databag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"databag/internal/store"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
//Disable multi-factor auth for admin
|
||||||
|
func RemoveAdminMFAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
// validate login
|
||||||
|
if code, err := ParamSessionToken(r); err != nil {
|
||||||
|
ErrResponse(w, code, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := store.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if res := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "config_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"str_value"}),
|
||||||
|
}).Create(&store.Config{ConfigID: CNFMFAConfirmed, BoolValue: false}).Error; res != nil {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if res := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "config_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"str_value"}),
|
||||||
|
}).Create(&store.Config{ConfigID: CNFMFAEnabled, BoolValue: false}).Error; res != nil {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ErrResponse(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteResponse(w, nil)
|
||||||
|
}
|
@ -21,7 +21,7 @@ func RemoveNodeAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if code, err := ParamAdminToken(r); err != nil {
|
if code, err := ParamSessionToken(r); err != nil {
|
||||||
ErrResponse(w, code, err)
|
ErrResponse(w, code, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
45
net/server/internal/api_setAdminAccess.go
Normal file
45
net/server/internal/api_setAdminAccess.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package databag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"github.com/theckman/go-securerandom"
|
||||||
|
"databag/internal/store"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//SetAdminAccess begins a session for admin access
|
||||||
|
func SetAdminAccess(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
// validate login
|
||||||
|
if code, err := ParamAdminToken(r); err != nil {
|
||||||
|
ErrResponse(w, code, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// gernate app token
|
||||||
|
data, err := securerandom.Bytes(APPTokenSize)
|
||||||
|
if err != nil {
|
||||||
|
ErrResponse(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
access := hex.EncodeToString(data)
|
||||||
|
|
||||||
|
err = store.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// upsert mfa enabled
|
||||||
|
if res := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "config_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"str_value"}),
|
||||||
|
}).Create(&store.Config{ConfigID: CNFAdminSession, StrValue: access}).Error; res != nil {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteResponse(w, access)
|
||||||
|
}
|
92
net/server/internal/api_setAdminMFAuth.go
Normal file
92
net/server/internal/api_setAdminMFAuth.go
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
package databag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"databag/internal/store"
|
||||||
|
"github.com/pquerna/otp"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"net/http"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//SetMultiFactorAuth
|
||||||
|
func SetAdminMFAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
// validate login
|
||||||
|
if code, err := ParamSessionToken(r); err != nil {
|
||||||
|
ErrResponse(w, code, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !getBoolConfigValue(CNFMFAEnabled, false) {
|
||||||
|
ErrResponse(w, http.StatusMethodNotAllowed, errors.New("totp not enabled"))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
code := r.FormValue("code")
|
||||||
|
if code == "" {
|
||||||
|
ErrResponse(w, http.StatusMethodNotAllowed, errors.New("totp code required"))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
curTime := time.Now().Unix()
|
||||||
|
failedTime := getNumConfigValue(CNFMFAFailedTime, 0);
|
||||||
|
failedCount := getNumConfigValue(CNFMFAFailedCount, 0);
|
||||||
|
if failedTime + APPMFAFailPeriod > curTime && failedCount > APPMFAFailCount {
|
||||||
|
ErrResponse(w, http.StatusTooManyRequests, errors.New("temporarily locked"))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := getStrConfigValue(CNFMFASecret, "");
|
||||||
|
opts := totp.ValidateOpts{Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA256}
|
||||||
|
if valid, _ := totp.ValidateCustom(code, secret, time.Now(), opts); !valid {
|
||||||
|
err := store.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if failedTime + APPMFAFailPeriod > curTime {
|
||||||
|
if res := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "config_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"num_value"}),
|
||||||
|
}).Create(&store.Config{ConfigID: CNFMFAFailedCount, NumValue: failedCount + 1}).Error; res != nil {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if res := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "config_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"num_value"}),
|
||||||
|
}).Create(&store.Config{ConfigID: CNFMFAFailedTime, NumValue: curTime}).Error; res != nil {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if res := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "config_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"num_value"}),
|
||||||
|
}).Create(&store.Config{ConfigID: CNFMFAFailedCount, NumValue: failedCount + 1}).Error; res != nil {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
LogMsg("failed to increment fail count");
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrResponse(w, http.StatusUnauthorized, errors.New("invalid code"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := store.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// upsert mfa confirmed
|
||||||
|
if res := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "config_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"str_value"}),
|
||||||
|
}).Create(&store.Config{ConfigID: CNFMFAConfirmed, BoolValue: true}).Error; res != nil {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ErrResponse(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteResponse(w, nil)
|
||||||
|
}
|
@ -18,7 +18,7 @@ func SetNodeAccountStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if code, err := ParamAdminToken(r); err != nil {
|
if code, err := ParamSessionToken(r); err != nil {
|
||||||
ErrResponse(w, code, err)
|
ErrResponse(w, code, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ import (
|
|||||||
func SetNodeConfig(w http.ResponseWriter, r *http.Request) {
|
func SetNodeConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// validate login
|
// validate login
|
||||||
if code, err := ParamAdminToken(r); err != nil {
|
if code, err := ParamSessionToken(r); err != nil {
|
||||||
ErrResponse(w, code, err)
|
ErrResponse(w, code, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -95,6 +95,30 @@ func ParamAdminToken(r *http.Request) (int, error) {
|
|||||||
return http.StatusOK, nil
|
return http.StatusOK, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//ParamSessionToken compares session token with token query param
|
||||||
|
func ParamSessionToken(r *http.Request) (int, error) {
|
||||||
|
|
||||||
|
// parse authentication token
|
||||||
|
token := r.FormValue("token")
|
||||||
|
if token == "" {
|
||||||
|
return http.StatusUnauthorized, errors.New("token not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing to do if not configured
|
||||||
|
if !getBoolConfigValue(CNFConfigured, false) {
|
||||||
|
return http.StatusUnauthorized, errors.New("node not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// compare password
|
||||||
|
value := getStrConfigValue(CNFAdminSession, "")
|
||||||
|
if value != token {
|
||||||
|
return http.StatusUnauthorized, errors.New("invalid session token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.StatusOK, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//GetSessionDetail retrieves account detail specified by agent query param
|
//GetSessionDetail retrieves account detail specified by agent query param
|
||||||
func GetSessionDetail(r *http.Request) (*store.Session, int, error) {
|
func GetSessionDetail(r *http.Request) (*store.Session, int, error) {
|
||||||
|
|
||||||
|
@ -63,6 +63,23 @@ const CNFIceUsername = "ice_username"
|
|||||||
//CNFIceUrl specifies the ice candidate url
|
//CNFIceUrl specifies the ice candidate url
|
||||||
const CNFIcePassword = "ice_password"
|
const CNFIcePassword = "ice_password"
|
||||||
|
|
||||||
|
//CNFMFAFailedTime start of mfa failure window
|
||||||
|
const CNFMFAFailedTime = "mfa_failed_time"
|
||||||
|
|
||||||
|
//CNFMFAFailedCount number of failures in window
|
||||||
|
const CNFMFAFailedCount = "mfa_failed_count"
|
||||||
|
|
||||||
|
//CNFMFARequired specified if mfa enabled for admin
|
||||||
|
const CNFMFAEnabled = "mfa_enabled"
|
||||||
|
|
||||||
|
//CNFMFAConfirmed specified if mfa has been confirmed for admin
|
||||||
|
const CNFMFAConfirmed = "mfa_confirmed"
|
||||||
|
|
||||||
|
//CNFMFASecret specified the mfa secret
|
||||||
|
const CNFMFASecret = "mfa_secret"
|
||||||
|
|
||||||
|
//CNFAdminSession sepcifies the admin session token
|
||||||
|
const CNFAdminSession = "admin_session"
|
||||||
|
|
||||||
func getStrConfigValue(configID string, empty string) string {
|
func getStrConfigValue(configID string, empty string) string {
|
||||||
var config store.Config
|
var config store.Config
|
||||||
|
@ -279,6 +279,41 @@ var endpoints = routes{
|
|||||||
ImportAccount,
|
ImportAccount,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
route{
|
||||||
|
"SetAdminAccess",
|
||||||
|
strings.ToUpper("Put"),
|
||||||
|
"/admin/access",
|
||||||
|
SetAdminAccess,
|
||||||
|
},
|
||||||
|
|
||||||
|
route{
|
||||||
|
"GetAdminMFAuth",
|
||||||
|
strings.ToUpper("Get"),
|
||||||
|
"/admin/mfauth",
|
||||||
|
GetAdminMFAuth,
|
||||||
|
},
|
||||||
|
|
||||||
|
route{
|
||||||
|
"AddAdminMFAuth",
|
||||||
|
strings.ToUpper("Post"),
|
||||||
|
"/admin/mfauth",
|
||||||
|
AddAdminMFAuth,
|
||||||
|
},
|
||||||
|
|
||||||
|
route{
|
||||||
|
"SetAdminMFAuth",
|
||||||
|
strings.ToUpper("Put"),
|
||||||
|
"/admin/mfauth",
|
||||||
|
SetAdminMFAuth,
|
||||||
|
},
|
||||||
|
|
||||||
|
route{
|
||||||
|
"RemoveAdminMFAuth",
|
||||||
|
strings.ToUpper("Delete"),
|
||||||
|
"/admin/mfauth",
|
||||||
|
RemoveAdminMFAuth,
|
||||||
|
},
|
||||||
|
|
||||||
route{
|
route{
|
||||||
"RemoveNodeAccount",
|
"RemoveNodeAccount",
|
||||||
strings.ToUpper("Delete"),
|
strings.ToUpper("Delete"),
|
||||||
|
@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { getNodeStatus } from 'api/getNodeStatus';
|
import { getNodeStatus } from 'api/getNodeStatus';
|
||||||
import { setNodeStatus } from 'api/setNodeStatus';
|
import { setNodeStatus } from 'api/setNodeStatus';
|
||||||
import { getNodeConfig } from 'api/getNodeConfig';
|
import { getNodeConfig } from 'api/getNodeConfig';
|
||||||
|
import { setNodeAccess } from 'api/setNodeAccess';
|
||||||
import { AppContext } from 'context/AppContext';
|
import { AppContext } from 'context/AppContext';
|
||||||
import { SettingsContext } from 'context/SettingsContext';
|
import { SettingsContext } from 'context/SettingsContext';
|
||||||
|
|
||||||
@ -52,9 +53,10 @@ export function useAdmin() {
|
|||||||
if (state.unclaimed === true) {
|
if (state.unclaimed === true) {
|
||||||
await setNodeStatus(state.password);
|
await setNodeStatus(state.password);
|
||||||
}
|
}
|
||||||
await getNodeConfig(state.password);
|
const session = await setNodeAccess(state.password);
|
||||||
|
|
||||||
updateState({ busy: false });
|
updateState({ busy: false });
|
||||||
app.actions.setAdmin(state.password);
|
app.actions.setAdmin(session);
|
||||||
}
|
}
|
||||||
catch(err) {
|
catch(err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
|
8
net/web/src/api/getAdminMFAuth.js
Normal file
8
net/web/src/api/getAdminMFAuth.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||||
|
|
||||||
|
export async function getAdminMFAuth(token) {
|
||||||
|
const mfa = await fetchWithTimeout(`/admin/mfauth?token=${encodeURIComponent(token)}`, { method: 'GET' });
|
||||||
|
checkResponse(mfa);
|
||||||
|
return await mfa.json();
|
||||||
|
}
|
||||||
|
|
8
net/web/src/api/setNodeAccess.js
Normal file
8
net/web/src/api/setNodeAccess.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||||
|
|
||||||
|
export async function setNodeAccess(token) {
|
||||||
|
const access = await fetchWithTimeout(`/admin/access?token=${encodeURIComponent(token)}`, { method: 'PUT' });
|
||||||
|
checkResponse(access);
|
||||||
|
return access.json()
|
||||||
|
}
|
||||||
|
|
@ -195,6 +195,13 @@ export const en = {
|
|||||||
mfaDisabled: 'verification temporarily disabled',
|
mfaDisabled: 'verification temporarily disabled',
|
||||||
mfaConfirm: 'Confirm',
|
mfaConfirm: 'Confirm',
|
||||||
mfaEnter: 'Enter your verification code',
|
mfaEnter: 'Enter your verification code',
|
||||||
|
|
||||||
|
enableMultifactor: 'Enable multi-factor authentication',
|
||||||
|
disableMultifactor: 'Disable multi-factor authentication',
|
||||||
|
|
||||||
|
disable: 'Disable',
|
||||||
|
confirmDisable: 'Disabling Multi-Factor Authentication',
|
||||||
|
disablePrompt: 'Are you sure you want to disable multi-factor authentication',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fr = {
|
export const fr = {
|
||||||
@ -395,6 +402,13 @@ export const fr = {
|
|||||||
mfaError: 'erreur de code de vérification',
|
mfaError: 'erreur de code de vérification',
|
||||||
mfaDisabled: 'vérification temporairement désactivée',
|
mfaDisabled: 'vérification temporairement désactivée',
|
||||||
mfaConfirm: 'Confirmer',
|
mfaConfirm: 'Confirmer',
|
||||||
|
|
||||||
|
enableMultifactor: 'Activer l\'authentification multifacteur',
|
||||||
|
disableMultifactor: 'Désactiver l\'authentification multifacteur',
|
||||||
|
|
||||||
|
disable: 'Désactiver',
|
||||||
|
confirmDisable: 'Désactivation de l\'authentification multi-facteurs',
|
||||||
|
disablePrompt: 'Êtes-vous sûr de vouloir désactiver l\'authentification multi-facteurs',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sp = {
|
export const sp = {
|
||||||
@ -594,6 +608,13 @@ export const sp = {
|
|||||||
mfaError: 'error de código de verificación',
|
mfaError: 'error de código de verificación',
|
||||||
mfaDisabled: 'verificación temporalmente deshabilitada',
|
mfaDisabled: 'verificación temporalmente deshabilitada',
|
||||||
mfaConfirm: 'Confirmar',
|
mfaConfirm: 'Confirmar',
|
||||||
|
|
||||||
|
enableMultifactor: 'Habilitar la autenticación multifactor',
|
||||||
|
disableMultifactor: 'Deshabilitar la autenticación multifactor',
|
||||||
|
|
||||||
|
disable: 'Desactivar',
|
||||||
|
confirmDisable: 'Desactivación de la autenticación de dos factores',
|
||||||
|
disablePrompt: '¿Estás seguro de que quieres desactivar la autenticación de dos factores?',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pt = {
|
export const pt = {
|
||||||
@ -793,6 +814,13 @@ export const pt = {
|
|||||||
mfaError: 'erro de código de verificação',
|
mfaError: 'erro de código de verificação',
|
||||||
mfaDisabled: 'verificação temporariamente desativada',
|
mfaDisabled: 'verificação temporariamente desativada',
|
||||||
mfaConfirm: 'Confirmar',
|
mfaConfirm: 'Confirmar',
|
||||||
|
|
||||||
|
enableMultifactor: 'Habilitar autenticação multifator',
|
||||||
|
disableMultifactor: 'Desativar autenticação multifator',
|
||||||
|
|
||||||
|
disable: 'Desativar',
|
||||||
|
confirmDisable: 'Desativando Autenticação de Dois Fatores',
|
||||||
|
disablePrompt: 'Tem certeza de que deseja desativar a autenticação de dois fatores?',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const de = {
|
export const de = {
|
||||||
@ -992,6 +1020,13 @@ export const de = {
|
|||||||
mfaError: 'Verifizierungscodefehler',
|
mfaError: 'Verifizierungscodefehler',
|
||||||
mfaDisabled: 'Verifizierung vorübergehend deaktiviert',
|
mfaDisabled: 'Verifizierung vorübergehend deaktiviert',
|
||||||
mfaConfirm: 'Bestätigen',
|
mfaConfirm: 'Bestätigen',
|
||||||
|
|
||||||
|
enableMultifactor: 'Aktivieren Sie die Multi-Faktor-Authentifizierung',
|
||||||
|
disableMultifactor: 'Deaktivieren Sie die Multi-Faktor-Authentifizierung',
|
||||||
|
|
||||||
|
disable: 'Deaktivieren',
|
||||||
|
confirmDisable: 'Deaktivierung der Zwei-Faktor-Authentifizierung',
|
||||||
|
disablePrompt: 'Sind Sie sicher, dass Sie die Zwei-Faktor-Authentifizierung deaktivieren möchten?',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ru = {
|
export const ru = {
|
||||||
@ -1191,4 +1226,11 @@ export const ru = {
|
|||||||
mfaError: 'ошибка проверочного кода',
|
mfaError: 'ошибка проверочного кода',
|
||||||
mfaDisabled: 'проверка временно отключена',
|
mfaDisabled: 'проверка временно отключена',
|
||||||
mfaConfirm: 'Подтвердить',
|
mfaConfirm: 'Подтвердить',
|
||||||
|
|
||||||
|
enableMultifactor: 'Включить многофакторную аутентификацию',
|
||||||
|
disableMultifactor: 'Отключить многофакторную аутентификацию',
|
||||||
|
|
||||||
|
disable: 'Отключить',
|
||||||
|
confirmDisable: 'Отключение двухфакторной аутентификации',
|
||||||
|
disablePrompt: 'Вы уверены, что хотите отключить двухфакторную аутентификацию?',
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { AlertIcon, DashboardWrapper, SettingsButton, AddButton, SettingsLayout, CreateLayout } from './Dashboard.styled';
|
import { AlertIcon, DashboardWrapper, SettingsButton, AddButton, SettingsLayout, CreateLayout } from './Dashboard.styled';
|
||||||
import { Tooltip, Switch, Select, Button, Space, Modal, Input, InputNumber, List } from 'antd';
|
import { Tooltip, Switch, Select, Button, Space, Modal, Input, InputNumber, List } from 'antd';
|
||||||
import { ExclamationCircleOutlined, SettingOutlined, UserAddOutlined, LogoutOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { ExclamationCircleOutlined, SettingOutlined, UserAddOutlined, LogoutOutlined, ReloadOutlined, LockOutlined, UnlockOutlined } from '@ant-design/icons';
|
||||||
import { ThemeProvider } from "styled-components";
|
import { ThemeProvider } from "styled-components";
|
||||||
import { useDashboard } from './useDashboard.hook';
|
import { useDashboard } from './useDashboard.hook';
|
||||||
import { AccountItem } from './accountItem/AccountItem';
|
import { AccountItem } from './accountItem/AccountItem';
|
||||||
@ -8,6 +8,7 @@ import { CopyButton } from '../copyButton/CopyButton';
|
|||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
|
|
||||||
|
const [ modal, modalContext ] = Modal.useModal();
|
||||||
const { state, actions } = useDashboard();
|
const { state, actions } = useDashboard();
|
||||||
|
|
||||||
const onClipboard = async (value) => {
|
const onClipboard = async (value) => {
|
||||||
@ -18,9 +19,26 @@ export function Dashboard() {
|
|||||||
return window.location.origin + '/#/create?add=' + state.createToken;
|
return window.location.origin + '/#/create?add=' + state.createToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const disableMFA = () => {
|
||||||
|
modal.confirm({
|
||||||
|
title: <span style={state.menuStyle}>{state.strings.confirmDisable}</span>,
|
||||||
|
content: <span style={state.menuStyle}>{state.strings.disablePrompt}</span>,
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
bodyStyle: { borderRadius: 8, padding: 16, ...state.menuStyle },
|
||||||
|
okText: state.strings.disable,
|
||||||
|
cancelText: state.strings.cancel,
|
||||||
|
onOk() {
|
||||||
|
actions.disableMFA();
|
||||||
|
},
|
||||||
|
onCancel() {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={state.colors}>
|
<ThemeProvider theme={state.colors}>
|
||||||
<DashboardWrapper>
|
<DashboardWrapper>
|
||||||
|
{ modalContext }
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<div className="label">{ state.strings.accounts }</div>
|
<div className="label">{ state.strings.accounts }</div>
|
||||||
@ -34,6 +52,18 @@ export function Dashboard() {
|
|||||||
<SettingsButton type="text" size="small" icon={<SettingOutlined />}
|
<SettingsButton type="text" size="small" icon={<SettingOutlined />}
|
||||||
onClick={() => actions.setShowSettings(true)}></SettingsButton>
|
onClick={() => actions.setShowSettings(true)}></SettingsButton>
|
||||||
</div>
|
</div>
|
||||||
|
{ (state.mfAuthSet && state.mfaAuthEnabled) && (
|
||||||
|
<div className="settings">
|
||||||
|
<SettingsButton type="text" size="small" icon={<UnlockOutlined />}
|
||||||
|
onClick={disableMFA}></SettingsButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ (state.mfAuthSet && !state.mfaAuthEnabled) && (
|
||||||
|
<div className="settings">
|
||||||
|
<SettingsButton type="text" size="small" icon={<LockOutlined />}
|
||||||
|
onClick={actions.enableMFA}></SettingsButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="settings">
|
<div className="settings">
|
||||||
<SettingsButton type="text" size="small" icon={<LogoutOutlined />}
|
<SettingsButton type="text" size="small" icon={<LogoutOutlined />}
|
||||||
onClick={() => actions.logout()}></SettingsButton>
|
onClick={() => actions.logout()}></SettingsButton>
|
||||||
@ -63,6 +93,22 @@ export function Dashboard() {
|
|||||||
onClick={() => actions.setShowSettings(true)}></SettingsButton>
|
onClick={() => actions.setShowSettings(true)}></SettingsButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
{ (state.mfAuthSet && state.mfaAuthEnabled) && (
|
||||||
|
<div className="settings">
|
||||||
|
<Tooltip placement="topRight" title={state.strings.disableMultifactor}>
|
||||||
|
<SettingsButton type="text" size="small" icon={<LockOutlined />}
|
||||||
|
onClick={disableMFA}></SettingsButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ (state.mfAuthSet && !state.mfaAuthEnabled) && (
|
||||||
|
<div className="settings">
|
||||||
|
<Tooltip placement="topRight" title={state.strings.enableMultifactor}>
|
||||||
|
<SettingsButton type="text" size="small" icon={<UnlockOutlined />}
|
||||||
|
onClick={actions.enableMFA}></SettingsButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="settings">
|
<div className="settings">
|
||||||
<Tooltip placement="topRight" title={state.strings.logout}>
|
<Tooltip placement="topRight" title={state.strings.logout}>
|
||||||
<SettingsButton type="text" size="small" icon={<LogoutOutlined />}
|
<SettingsButton type="text" size="small" icon={<LogoutOutlined />}
|
||||||
|
@ -8,7 +8,10 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { AppContext } from 'context/AppContext';
|
import { AppContext } from 'context/AppContext';
|
||||||
import { SettingsContext } from 'context/SettingsContext';
|
import { SettingsContext } from 'context/SettingsContext';
|
||||||
|
|
||||||
export function useDashboard() {
|
import { getAdminMFAuth } from 'api/getAdminMFAuth';
|
||||||
|
|
||||||
|
|
||||||
|
export function useDashboard(token) {
|
||||||
|
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
||||||
domain: "",
|
domain: "",
|
||||||
@ -38,6 +41,12 @@ export function useDashboard() {
|
|||||||
colors: {},
|
colors: {},
|
||||||
menuStyle: {},
|
menuStyle: {},
|
||||||
strings: {},
|
strings: {},
|
||||||
|
|
||||||
|
mfAuthSet: false,
|
||||||
|
mfAuthEnabled: false,
|
||||||
|
mfAuthSecretText: null,
|
||||||
|
mfAuthSecretImage: null,
|
||||||
|
mfaAuthError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -140,6 +149,10 @@ export function useDashboard() {
|
|||||||
await syncConfig();
|
await syncConfig();
|
||||||
await syncAccounts();
|
await syncAccounts();
|
||||||
},
|
},
|
||||||
|
enableMFA: async () => {
|
||||||
|
},
|
||||||
|
disableMFA: async () => {
|
||||||
|
},
|
||||||
setSettings: async () => {
|
setSettings: async () => {
|
||||||
if (!state.busy) {
|
if (!state.busy) {
|
||||||
updateState({ busy: true });
|
updateState({ busy: true });
|
||||||
@ -161,10 +174,11 @@ export function useDashboard() {
|
|||||||
|
|
||||||
const syncConfig = async () => {
|
const syncConfig = async () => {
|
||||||
try {
|
try {
|
||||||
|
const enabled = await getAdminMFAuth(app.state.adminToken);
|
||||||
const config = await getNodeConfig(app.state.adminToken);
|
const config = await getNodeConfig(app.state.adminToken);
|
||||||
const { accountStorage, domain, keyType, pushSupported, transformSupported, allowUnsealed, enableImage, enableAudio, enableVideo, enableBinary, enableIce, iceUrl, iceUsername, icePassword, enableOpenAccess, openAccessLimit } = config;
|
const { accountStorage, domain, keyType, pushSupported, transformSupported, allowUnsealed, enableImage, enableAudio, enableVideo, enableBinary, enableIce, iceUrl, iceUsername, icePassword, enableOpenAccess, openAccessLimit } = config;
|
||||||
const storage = Math.ceil(accountStorage / 1073741824);
|
const storage = Math.ceil(accountStorage / 1073741824);
|
||||||
updateState({ configError: false, domain, accountStorage: storage, keyType, enableImage, enableAudio, enableVideo, enableBinary, pushSupported, transformSupported, allowUnsealed, enableIce, iceUrl, iceUsername, icePassword, enableOpenAccess, openAccessLimit });
|
updateState({ mfAuthSet: true, mfaAuthEnabled: enabled, configError: false, domain, accountStorage: storage, keyType, enableImage, enableAudio, enableVideo, enableBinary, pushSupported, transformSupported, allowUnsealed, enableIce, iceUrl, iceUsername, icePassword, enableOpenAccess, openAccessLimit });
|
||||||
}
|
}
|
||||||
catch(err) {
|
catch(err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
|
Loading…
Reference in New Issue
Block a user