diff --git a/internal/common/common.go b/internal/common/common.go index 816c4e7..736879f 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -1,3 +1,4 @@ +// common contains common functionality for blasphem. package common import ( @@ -5,6 +6,7 @@ import ( ) const ( + // AppName is the name of the application. AppName = "blasphem" ) @@ -13,6 +15,7 @@ type cmdOptions interface { Execute() error } +// RunE is a convenience function for use with cobra. func RunE(c cmdOptions) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { err := c.Options(cmd, args) diff --git a/pkg/auth/authenticator.go b/pkg/auth/authenticator.go index 1c0d637..1e19f49 100644 --- a/pkg/auth/authenticator.go +++ b/pkg/auth/authenticator.go @@ -1,12 +1,9 @@ package auth import ( - "crypto/rand" - "encoding/hex" "errors" "net/http" - "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/rs/zerolog/log" @@ -28,7 +25,7 @@ var ( type Authenticator struct { store AuthStore flows FlowStore - sessions SessionStore + sessions AccessSessionStore providers map[string]provider.AuthProvider } @@ -114,26 +111,10 @@ func (a *Authenticator) Check(f *Flow, req *http.Request, rm map[string]interfac user, success := p.ValidateCreds(req, rm) if success { - log.Info().Interface("user", user.ProviderUserData()).Msg("Login success") + log.Info().Interface("user", user.UserData()).Msg("Login success") return user, nil } } return nil, ErrInvalidAuth } - -func genUUID() string { - // must be addressable - u := uuid.New() - - return hex.EncodeToString(u[:]) -} - -func genHex(l int) string { - b := make([]byte, l) - if _, err := rand.Read(b); err != nil { - panic(err) - } - - return hex.EncodeToString(b) -} diff --git a/pkg/auth/flow.go b/pkg/auth/flow.go index 861f417..e32c381 100644 --- a/pkg/auth/flow.go +++ b/pkg/auth/flow.go @@ -9,6 +9,7 @@ import ( "github.com/labstack/echo/v4" "dynatron.me/x/blasphem/internal/common" + "dynatron.me/x/blasphem/internal/generate" "dynatron.me/x/blasphem/pkg/auth/provider" ) @@ -100,7 +101,7 @@ func (a *Authenticator) NewFlow(r *FlowRequest) *Flow { flow := &Flow{ Type: TypeForm, - ID: FlowID(genUUID()), + ID: FlowID(generate.UUID()), StepID: stepPtr(StepInit), Schema: sch, Handler: r.Handler, @@ -146,12 +147,12 @@ func (f *Flow) progress(a *Authenticator, c echo.Context) error { switch err { case nil: var finishedFlow struct { - ID FlowID `json:"flow_id"` - Handler []*string `json:"handler"` - Result TokenID `json:"result"` - Title string `json:"title"` - Type FlowType `json:"type"` - Version int `json:"version"` + ID FlowID `json:"flow_id"` + Handler []*string `json:"handler"` + Result AccessTokenID `json:"result"` + Title string `json:"title"` + Type FlowType `json:"type"` + Version int `json:"version"` } a.flows.Remove(f) @@ -159,7 +160,7 @@ func (f *Flow) progress(a *Authenticator, c echo.Context) error { finishedFlow.Type = TypeCreateEntry finishedFlow.Title = common.AppName finishedFlow.Version = 1 - finishedFlow.Result = a.NewToken(c.Request(), user, f) + finishedFlow.Result = a.NewAccessToken(c.Request(), user, f) f.redirect(c) diff --git a/pkg/auth/provider/hass/provider.go b/pkg/auth/provider/hass/provider.go index df95a5d..04b3d3e 100644 --- a/pkg/auth/provider/hass/provider.go +++ b/pkg/auth/provider/hass/provider.go @@ -23,8 +23,8 @@ type HAUser struct { provider.AuthProvider `json:"-"` } -func (hau *HAUser) UserData() interface{} { - return UserData{ +func (hau *HAUser) UserData() provider.ProviderUser { + return &UserData{ Username: hau.Username, } } @@ -33,6 +33,10 @@ type UserData struct { Username string `json:"username"` } +func (ud *UserData) UserData() provider.ProviderUser { + return ud +} + const HomeAssistant = "homeassistant" func (h *HAUser) ProviderUserData() interface{} { return h.UserData() } diff --git a/pkg/auth/provider/provider.go b/pkg/auth/provider/provider.go index 3a2e903..b8f3bde 100644 --- a/pkg/auth/provider/provider.go +++ b/pkg/auth/provider/provider.go @@ -23,8 +23,7 @@ func Register(providerName string, f func(storage.Store) (AuthProvider, error)) } type ProviderUser interface { - AuthProviderMetadata - ProviderUserData() interface{} + UserData() ProviderUser } type AuthProviderBase struct { diff --git a/pkg/auth/provider/trustednets/trustednets.go b/pkg/auth/provider/trustednets/trustednets.go index ccc3443..d3ae9f3 100644 --- a/pkg/auth/provider/trustednets/trustednets.go +++ b/pkg/auth/provider/trustednets/trustednets.go @@ -15,8 +15,8 @@ type User struct { provider.AuthProvider `json:"-"` } -func (hau *User) UserData() interface{} { - return UserData{ +func (hau *User) UserData() provider.ProviderUser { + return &UserData{ UserID: hau.UserID, } } @@ -25,9 +25,11 @@ type UserData struct { UserID string `json:"user_id"` } -const TrustedNetworks = "trusted_networks" +func (ud *UserData) UserData() provider.ProviderUser { + return ud +} -func (h *User) ProviderUserData() interface{} { return h.UserData() } +const TrustedNetworks = "trusted_networks" type TrustedNetworksProvider struct { provider.AuthProviderBase `json:"-"` diff --git a/pkg/auth/session.go b/pkg/auth/session.go index 1e709b1..14cb985 100644 --- a/pkg/auth/session.go +++ b/pkg/auth/session.go @@ -2,28 +2,97 @@ package auth import ( "encoding/json" + "fmt" "net/http" + "strconv" + "strings" "time" "github.com/labstack/echo/v4" + "dynatron.me/x/blasphem/internal/generate" "dynatron.me/x/blasphem/pkg/auth/provider" ) -type SessionStore struct { - s map[TokenID]*Token +/* + { + "id": "18912b07b66e48558a44c058bf90f1d4", + "user_id": "1bd642b0ed9f410280f622d1d358102b", + "client_id": "https://oauth-redirect.googleusercontent.com/", + "client_name": null, + "client_icon": null, + "token_type": "normal", + "created_at": "2021-12-13T04:34:47.169033+00:00", + "access_token_expiration": 1800.0, + "token": "f93f87557ca616508c675f05b85921d07fdf764efc34c74a81daeebd71ab899a6e8cd5dec94d0ae36499ff281f2efcf715c763ad73eabadd2f586b827057043d", + "jwt_key": "f4507c01fe19b1d99c4b628cadc8208f7838dbdda0a9a051fd029cefdf6f619b0728d6b0a4d3d96ee68614b035054952faa14c48ae36bd212e28d602864f6d1c", + "last_used_at": "2021-12-13T04:34:47.169738+00:00", + "last_used_ip": "108.177.68.93", + "credential_id": "5daeb186d2a943328cbb984f135974cb", + "version": "2021.12.0" + }, +*/ + +type ( + TokenType string + TokenTimestamp time.Time + RefreshTokenID string + ExpSeconds float64 +) + +func (f ExpSeconds) MarshalJSON() ([]byte, error) { + if float64(f) == float64(int(f)) { + return []byte(strconv.FormatFloat(float64(f), 'f', 1, 32)), nil + } + return []byte(strconv.FormatFloat(float64(f), 'f', -1, 32)), nil +} + +const PytTimeFormat = "2006-01-02T15:04:05.999999-07:00" + +func (t *TokenTimestamp) MarshalJSON() ([]byte, error) { + rv := fmt.Sprintf("%q", time.Time(*t).Format(PytTimeFormat)) + return []byte(rv), nil +} + +func (t *TokenTimestamp) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), `"`) + tm, err := time.Parse(PytTimeFormat, s) + *t = TokenTimestamp(tm) + + return err +} + +type RefreshToken struct { + ID RefreshTokenID `json:"id"` + UserID UserID `json:"user_id"` + ClientID *ClientID `json:"client_id"` + ClientName *string `json:"client_name"` + ClientIcon *string `json:"client_icon"` + TokenType TokenType `json:"token_type"` + CreatedAt *TokenTimestamp `json:"created_at"` + AccessTokenExpiration ExpSeconds `json:"access_token_expiration"` + Token string `json:"token"` + JWTKey string `json:"jwt_key"` + LastUsedAt *TokenTimestamp `json:"last_used_at"` + LastUsedIP *string `json:"last_used_ip"` + CredentialID *CredID `json:"credential_id"` + Version *string `json:"version"` +} + +type AccessSessionStore struct { + s map[AccessTokenID]*AccessToken lastCull time.Time } -type TokenID string +type AccessTokenID string -func (t *TokenID) IsValid() bool { +func (t *AccessTokenID) IsValid() bool { // TODO: more validation than this return *t != "" } -type Token struct { // TODO: jwt bro - ID TokenID +type AccessToken struct { // TODO: jwt bro + ID AccessTokenID Ctime time.Time Expires time.Time Addr string @@ -31,13 +100,13 @@ type Token struct { // TODO: jwt bro user provider.ProviderUser `json:"-"` } -func (ss *SessionStore) init() { - ss.s = make(map[TokenID]*Token) +func (ss *AccessSessionStore) init() { + ss.s = make(map[AccessTokenID]*AccessToken) } const cullInterval = 5 * time.Minute -func (ss *SessionStore) cull() { +func (ss *AccessSessionStore) cull() { if now := time.Now(); now.Sub(ss.lastCull) > cullInterval { for k, v := range ss.s { if now.After(v.Expires) { @@ -47,12 +116,12 @@ func (ss *SessionStore) cull() { } } -func (ss *SessionStore) register(t *Token) { +func (ss *AccessSessionStore) register(t *AccessToken) { ss.cull() ss.s[t.ID] = t } -func (ss *SessionStore) verify(tr *TokenRequest, r *http.Request) (provider.ProviderUser, bool) { +func (ss *AccessSessionStore) verify(tr *TokenRequest, r *http.Request) (provider.ProviderUser, bool) { if t, hasToken := ss.s[tr.Code]; hasToken { // TODO: JWT if t.Expires.After(time.Now()) { @@ -75,18 +144,20 @@ type Credential struct { } func (cred *Credential) MarshalJSON() ([]byte, error) { - type CredAlias Credential + type CredAlias Credential // alias so ΓΈ method set and we don't recurse nCd := (*CredAlias)(cred) - providerData := cred.user.ProviderUserData() - if providerData != nil { - b, err := json.Marshal(providerData) - if err != nil { - return nil, err - } + if cred.user != nil { + providerData := cred.user.UserData() + if providerData != nil { + b, err := json.Marshal(providerData) + if err != nil { + return nil, err + } - dr := json.RawMessage(b) - nCd.DataRaw = &dr + dr := json.RawMessage(b) + nCd.DataRaw = &dr + } } return json.Marshal(nCd) @@ -105,13 +176,13 @@ func (a *Authenticator) verifyAndGetCredential(tr *TokenRequest, r *http.Request return cred } -const defaultExpiration = 2 * time.Hour +const defaultExpiration = 15 * time.Minute -func (a *Authenticator) NewToken(r *http.Request, user provider.ProviderUser, f *Flow) TokenID { - id := TokenID(genUUID()) +func (a *Authenticator) NewAccessToken(r *http.Request, user provider.ProviderUser, f *Flow) AccessTokenID { + id := AccessTokenID(generate.UUID()) now := time.Now() - t := &Token{ + t := &AccessToken{ ID: id, Ctime: now, Expires: now.Add(defaultExpiration), @@ -140,9 +211,9 @@ func (c *ClientID) IsValid() bool { } type TokenRequest struct { - ClientID ClientID `form:"client_id"` - Code TokenID `form:"code"` - GrantType GrantType `form:"grant_type"` + ClientID ClientID `form:"client_id"` + Code AccessTokenID `form:"code"` + GrantType GrantType `form:"grant_type"` } func (a *Authenticator) TokenHandler(c echo.Context) error { diff --git a/pkg/auth/store.go b/pkg/auth/store.go index 157319e..6f25cef 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -6,6 +6,7 @@ import ( "github.com/rs/zerolog/log" + "dynatron.me/x/blasphem/pkg/auth/provider" "dynatron.me/x/blasphem/pkg/storage" ) @@ -18,9 +19,10 @@ type AuthStore interface { } type authStore struct { - Users []User `json:"users"` - Groups interface{} `json:"groups"` - Credentials []Credential `json:"credentials"` + Users []User `json:"users"` + Groups []Group `json:"groups"` + Credentials []Credential `json:"credentials"` + Refresh []RefreshToken `json:"refresh_tokens"` userMap map[UserID]*User } @@ -42,13 +44,15 @@ func (a *Authenticator) newAuthStore(s storage.Store) (as *authStore, err error) return nil, fmt.Errorf("no such provider %s", c.AuthProviderType) } - pd := prov.NewCredData() - if c.DataRaw != nil { + pd := prov.NewCredData() + err := json.Unmarshal(*c.DataRaw, pd) if err != nil { return nil, err } + + c.user = pd.(provider.ProviderUser) } } diff --git a/pkg/auth/user.go b/pkg/auth/user.go index 6913e83..a721c22 100644 --- a/pkg/auth/user.go +++ b/pkg/auth/user.go @@ -8,6 +8,11 @@ type UserID string type GroupID string type CredID string +type Group struct { + ID GroupID `json:"id"` + Name string `json:"name"` +} + type User struct { ID UserID `json:"id"` GroupIDs []GroupID `json:"group_ids"` @@ -16,11 +21,11 @@ type User struct { } type UserMetadata struct { - Active bool `json:"is_active"` Owner bool `json:"is_owner"` - LocalOnly bool `json:"local_only"` - SystemGenerated bool `json:"system_generated"` + Active bool `json:"is_active"` Name string `json:"name"` + SystemGenerated bool `json:"system_generated"` + LocalOnly bool `json:"local_only"` } func (u *User) allowedToAuth() error { diff --git a/pkg/storage/fs.go b/pkg/storage/fs.go index 1ad67b4..ced9b81 100644 --- a/pkg/storage/fs.go +++ b/pkg/storage/fs.go @@ -7,22 +7,49 @@ import ( var ( ErrNoSuchKey = errors.New("no such key in store") + ErrKeyExists = errors.New("key already exists") ) +// Item is an item in a datastore. type Item interface { + // Item is lockable if updating data item directly. sync.Locker + + // Dirty sets the dirty flag for the item so it will be flushed. Dirty() + + // IsDirty gets the dirty flag for the item. IsDirty() bool + + // GetData gets the data for the item. GetData() interface{} + + // GetData sets the data for the item. SetData(interface{}) + + // ItemKey gets the key of the item. ItemKey() string } +// Store represents a datastore. type Store interface { + // GetItem loads the specified key from the store into data and returns the Item. + // If err is ErrKeyExists, Item will be the existing item. GetItem(key string, data interface{}) (Item, error) + + // Get is the same as GetItem, but only returns error. Get(key string, data interface{}) error + + // Put puts the specified key into the store. If the key already exists, it clobbers. + // Note that any existing items will then dangle. Put(key string, version, minorVersion int, secretMode bool, data interface{}) (Item, error) + + // FlushAll flushes the store to backing. FlushAll() []error + + // Flush flushes a single key to backing. Flush(key string) error + + // Shutdown is called to quiesce and shutdown the store. Shutdown() } diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index c051dad..f92c2b3 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -13,7 +13,7 @@ import ( ) var ( - IndentStr = strings.Repeat(" ", 4) + IndentStr = strings.Repeat(" ", 2) ) const ( @@ -76,7 +76,7 @@ func (s *fsStore) persist(it *item) error { it.Lock() defer it.Unlock() - f, err := os.OpenFile(path.Join(s.storeRoot, it.Key), os.O_WRONLY|os.O_CREATE, it.mode()) + f, err := os.OpenFile(path.Join(s.storeRoot, it.Key), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, it.mode()) if err != nil { return err } @@ -172,6 +172,11 @@ func (s *fsStore) Get(key string, data interface{}) error { } func (s *fsStore) GetItem(key string, data interface{}) (Item, error) { + exists := s.get(key) + if exists != nil { + return exists, ErrKeyExists + } + f, err := s.fs.Open(key) if err != nil { return nil, err