Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,14 @@
*.flv

.VSCodeCounter

# Local runtime data (default db.dir when running ./core from the repo)
config/config.json
config/db.json
config/totp.json
config/totp_trust.json
config/v1.json
config/sessions.json
config/db.*.json
config/cert/
config/sessions/
31 changes: 28 additions & 3 deletions app/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"time"

"github.com/datarhei/core/v16/app"
"github.com/datarhei/core/v16/auth/totp"
"github.com/datarhei/core/v16/config"
configstore "github.com/datarhei/core/v16/config/store"
configvars "github.com/datarhei/core/v16/config/vars"
Expand Down Expand Up @@ -81,6 +82,7 @@ type api struct {
mainserver *gohttp.Server
sidecarserver *gohttp.Server
httpjwt jwt.JWT
totp *totp.Store
update update.Checker
replacer replace.Replacer

Expand Down Expand Up @@ -582,6 +584,7 @@ func (a *api) start() error {
}

var store restreamstore.Store = nil
var totpStore *totp.Store = nil

{
fs, err := fs.NewRootedDiskFilesystem(fs.RootedDiskConfig{
Expand All @@ -598,8 +601,20 @@ func (a *api) start() error {
if err != nil {
return err
}

if cfg.API.Auth.Enable {
totpStore, err = totp.NewStore(totp.Config{
Filesystem: fs,
Issuer: cfg.Name,
})
if err != nil {
return fmt.Errorf("unable to create TOTP store: %w", err)
}
}
}

a.totp = totpStore

restream, err := restream.New(restream.Config{
ID: cfg.ID,
Name: cfg.Name,
Expand Down Expand Up @@ -630,13 +645,21 @@ func (a *api) start() error {
Realm: app.Name,
Secret: secret,
SkipLocalhost: cfg.API.Auth.DisableLocalhost,
TOTP: totpStore,
TOTPRequired: func() bool {
if totpStore != nil {
return totpStore.Enrolled()
}

return false
},
})

if err != nil {
return fmt.Errorf("unable to create JWT provider: %w", err)
}

if validator, err := jwt.NewLocalValidator(cfg.API.Auth.Username, cfg.API.Auth.Password); err == nil {
if validator, err := jwt.NewLocalValidator(cfg.API.Auth.Username, cfg.API.Auth.Password, totpStore); err == nil {
if err := httpjwt.AddValidator(app.Name, validator); err != nil {
return fmt.Errorf("unable to add local JWT validator: %w", err)
}
Expand Down Expand Up @@ -1014,8 +1037,10 @@ func (a *api) start() error {
},
RTMP: a.rtmpserver,
SRT: a.srtserver,
JWT: a.httpjwt,
Config: a.config.store,
JWT: a.httpjwt,
TOTP: a.totp,
TOTPUsername: cfg.API.Auth.Username,
Config: a.config.store,
Sessions: a.sessions,
Router: router,
ReadOnly: cfg.API.ReadOnly,
Expand Down
235 changes: 235 additions & 0 deletions auth/totp/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
package totp

import (
"fmt"
"os"
"sync"

"github.com/datarhei/core/v16/encoding/json"
"github.com/datarhei/core/v16/io/fs"

gojson "encoding/json"

"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)

const defaultFilepath = "/totp.json"

// Config holds the configuration for a TOTP store.
type Config struct {
Filesystem fs.Filesystem
Filepath string
Issuer string
}

// Status describes the current TOTP enrollment state.
type Status struct {
Enrolled bool `json:"enrolled"`
Pending bool `json:"pending"`
}

// Setup holds the data returned when starting TOTP enrollment.
type Setup struct {
Secret string `json:"secret"`
URI string `json:"uri"`
}

type fileData struct {
Enrolled bool `json:"enrolled"`
Pending bool `json:"pending"`
Secret string `json:"secret,omitempty"`
Username string `json:"username,omitempty"`
}

// Store persists TOTP enrollment state and validates codes.
type Store struct {
fs fs.Filesystem
path string
issuer string

lock sync.RWMutex
data fileData
}

// NewStore creates a TOTP store backed by the given filesystem.
func NewStore(config Config) (*Store, error) {
if config.Filesystem == nil {
return nil, fmt.Errorf("no valid filesystem provided")
}

path := config.Filepath
if len(path) == 0 {
path = defaultFilepath
}

issuer := config.Issuer
if len(issuer) == 0 {
issuer = "Restreamer"
}

s := &Store{
fs: config.Filesystem,
path: path,
issuer: issuer,
}

if err := s.load(); err != nil {
return nil, err
}

return s, nil
}

func (s *Store) load() error {
s.lock.Lock()
defer s.lock.Unlock()

_, err := s.fs.Stat(s.path)
if err != nil {
if os.IsNotExist(err) {
s.data = fileData{}
return nil
}

return err
}

jsondata, err := s.fs.ReadFile(s.path)
if err != nil {
return err
}

if len(jsondata) == 0 {
s.data = fileData{}
return nil
}

if err := gojson.Unmarshal(jsondata, &s.data); err != nil {
return json.FormatError(jsondata, err)
}

return nil
}

func (s *Store) persistLocked() error {
jsondata, err := gojson.MarshalIndent(&s.data, "", " ")
if err != nil {
return err
}

_, _, err = s.fs.WriteFileSafe(s.path, jsondata)
return err
}

// Status returns the current enrollment state.
func (s *Store) Status() Status {
s.lock.RLock()
defer s.lock.RUnlock()

return Status{
Enrolled: s.data.Enrolled,
Pending: s.data.Pending,
}
}

// Enrolled reports whether TOTP is active for login.
func (s *Store) Enrolled() bool {
s.lock.RLock()
defer s.lock.RUnlock()

return s.data.Enrolled
}

// Setup starts TOTP enrollment and returns the secret and otpauth URI.
func (s *Store) Setup(username string) (Setup, error) {
s.lock.Lock()
defer s.lock.Unlock()

if s.data.Enrolled {
return Setup{}, fmt.Errorf("TOTP is already enabled")
}

key, err := totp.Generate(totp.GenerateOpts{
Issuer: s.issuer,
AccountName: username,
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
return Setup{}, fmt.Errorf("failed to generate TOTP secret: %w", err)
}

s.data = fileData{
Pending: true,
Secret: key.Secret(),
Username: username,
}

if err := s.persistLocked(); err != nil {
return Setup{}, err
}

return Setup{
Secret: key.Secret(),
URI: key.URL(),
}, nil
}

// Enable confirms enrollment with a valid code from the authenticator app.
func (s *Store) Enable(code string) error {
s.lock.Lock()
defer s.lock.Unlock()

if s.data.Enrolled {
return fmt.Errorf("TOTP is already enabled")
}

if !s.data.Pending || len(s.data.Secret) == 0 {
return fmt.Errorf("TOTP setup has not been started")
}

if !totp.Validate(code, s.data.Secret) {
return fmt.Errorf("invalid TOTP code")
}

s.data.Enrolled = true
s.data.Pending = false

return s.persistLocked()
}

// Disable removes TOTP enrollment after validating the current code.
func (s *Store) Disable(code string) error {
s.lock.Lock()
defer s.lock.Unlock()

if !s.data.Enrolled {
return fmt.Errorf("TOTP is not enabled")
}

if !totp.Validate(code, s.data.Secret) {
return fmt.Errorf("invalid TOTP code")
}

s.data = fileData{}

if err := s.persistLocked(); err != nil {
return err
}

return s.clearTrustLocked()
}

// Validate checks a TOTP code against the enrolled secret.
func (s *Store) Validate(code string) bool {
s.lock.RLock()
defer s.lock.RUnlock()

if !s.data.Enrolled || len(s.data.Secret) == 0 {
return false
}

return totp.Validate(code, s.data.Secret)
}
Loading