From f400c8d100d638b71ed73cd4ad926545c2857b83 Mon Sep 17 00:00:00 2001 From: Paddy Foran Date: Mon, 19 Nov 2012 03:34:44 -0500 Subject: [PATCH] Initial commit. --- .gitignore | 4 + account.go | 559 ++++++++++++++++++++++++++++++ audit.go | 86 +++++ config.go | 20 ++ device.go | 4 + instrumentation.go.stub | 0 link.go | 4 + log.go | 56 +++ notification.go | 4 + radix.go | 19 + stats.go.stub | 0 twocloud.go | 30 ++ user.go | 751 ++++++++++++++++++++++++++++++++++++++++ 13 files changed, 1537 insertions(+) create mode 100644 .gitignore create mode 100644 account.go create mode 100644 audit.go create mode 100644 config.go create mode 100644 device.go create mode 100644 instrumentation.go.stub create mode 100644 link.go create mode 100644 log.go create mode 100644 notification.go create mode 100644 radix.go create mode 100644 stats.go.stub create mode 100644 twocloud.go create mode 100644 user.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abae863 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.8l +*.out +*.swp +.DS_Store diff --git a/account.go b/account.go new file mode 100644 index 0000000..487ef1e --- /dev/null +++ b/account.go @@ -0,0 +1,559 @@ +package twocloud + +import ( + "code.google.com/p/goauth2/oauth" + "encoding/json" + "errors" + "github.com/fzzbt/radix/redis" + "io/ioutil" + "net/http" + "secondbit.org/ruid" + "time" +) + +type Account struct { + Added time.Time `json:"added,omitempty"` + ID ruid.RUID `json:"id,omitempty"` + Provider string `json:"provider,omitempty"` + // Provided by the provider + ForeignID string `json:"foreign_id,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` + DisplayName string `json:"display_name,omitempty"` + GivenName string `json:"given_name,omitempty"` + FamilyName string `json:"family_name,omitempty"` + Picture string `json:"picture,omitempty"` + Locale string `json:"locale,omitempty"` + Timezone string `json:"timezone,omitempty"` + Gender string `json:"gender,omitempty"` + // private info that is stored, never shared + UserID ruid.RUID `json:"-"` + accessToken string + refreshToken string + expires time.Time +} + +func (account *Account) IsEmpty() bool { + return account.ID.String() == ruid.RUID(0).String() +} + +type googleAccount struct { + ID string `json:"id,omitempty"` + Email string `json:"email,omitempty"` + VerifiedEmail bool `json:"verified_email,omitempty"` + Name string `json:"name,omitempty"` + GivenName string `json:"given_name,omitempty"` + FamilyName string `json:"family_name,omitempty"` + Picture string `json:"picture,omitempty"` + Locale string `json:"locale,omitempty"` + Timezone string `json:"timezone,omitempty"` + Gender string `json:"gender,omitempty"` + Error *googleError `json:"error,omitempty"` +} + +type googleError struct { + StatusCode int `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +type OAuthError string + +func (err OAuthError) Error() string { + return string(err) +} + +var OAuthAuthError = errors.New("Invalid OAuth credentials.") + +func (r *RequestBundle) GetOAuthAuthURL(client_id, client_secret, callback_url, state string) string { + // start instrumentation + config := &oauth.Config{ + ClientId: client_id, + ClientSecret: client_secret, + Scope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://accounts.google.com/o/oauth2/token", + RedirectURL: callback_url, + } + // stop instrumentation + return config.AuthCodeURL(state) +} + +func (r *RequestBundle) GetOAuthAccessToken(auth_code string) (access string, refresh string, expiration time.Time, err error) { + // start instrumentation + config := &oauth.Config{ + ClientId: r.Config.OAuth.ClientID, + ClientSecret: r.Config.OAuth.ClientSecret, + Scope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://accounts.google.com/o/oauth2/token", + RedirectURL: r.Config.OAuth.CallbackURL, + } + t := &oauth.Transport{Config: config} + token, err := t.Exchange(auth_code) + if err != nil { + return "", "", time.Time{}, err + } + // stop instrumentation + return token.AccessToken, token.RefreshToken, token.Expiry, nil +} + +func (r *RequestBundle) GetAccount(access, refresh string, expiration time.Time) (Account, error) { + // start instrumentation + googAccount, err := r.getGoogleAccount(access, refresh, expiration) + if err != nil { + r.Log.Error(err.Error()) + return Account{}, err + } + account, err := r.getAccountByForeignID(googAccount.ID) + // add repo call to instrumentation + if err != nil { + r.Log.Error(err.Error()) + return Account{}, err + } + if !account.IsEmpty() { + return account, nil + } + account = Account{ + Added: time.Now(), + Provider: "google", + ForeignID: googAccount.ID, + Email: googAccount.Email, + EmailVerified: googAccount.VerifiedEmail, + DisplayName: googAccount.GivenName + " " + googAccount.FamilyName + " (" + googAccount.Email + ")", + GivenName: googAccount.GivenName, + FamilyName: googAccount.FamilyName, + Picture: googAccount.Picture, + Timezone: googAccount.Timezone, + Locale: googAccount.Locale, + Gender: googAccount.Gender, + UserID: ruid.RUID(0), + accessToken: access, + refreshToken: refresh, + expires: expiration, + } + id, err := gen.Generate([]byte(googAccount.ID)) + if err != nil { + r.Log.Error(err.Error()) + return Account{}, err + } + account.ID = id + err = r.storeAccount(account, false) + // add the repo request to the instrumentation + if err != nil { + r.Log.Error(err.Error()) + return Account{}, err + } + // stop the instrumentation + return account, nil +} + +func (r *RequestBundle) getGoogleAccount(access, refresh string, expiration time.Time) (googleAccount, error) { + // start instrumentation + config := &oauth.Config{ + ClientId: r.Config.OAuth.ClientID, + ClientSecret: r.Config.OAuth.ClientSecret, + Scope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://accounts.google.com/o/oauth2/token", + RedirectURL: r.Config.OAuth.CallbackURL, + } + token := &oauth.Token{ + AccessToken: access, + } + if refresh != "" { + token.RefreshToken = refresh + } + if !expiration.IsZero() { + token.Expiry = expiration + } + t := &oauth.Transport{ + Config: config, + Token: token, + } + req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v1/userinfo", nil) + if err != nil { + r.Log.Error(err.Error()) + return googleAccount{}, err + } + resp, err := t.RoundTrip(req) + if err != nil { + r.Log.Error(err.Error()) + return googleAccount{}, err + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + r.Log.Error(err.Error()) + return googleAccount{}, err + } + var googAccount googleAccount + err = json.Unmarshal(body, &googAccount) + if err != nil { + r.Log.Error(err.Error()) + return googleAccount{}, err + } + if googAccount.Error != nil { + if googAccount.Error.StatusCode == 401 { + return googleAccount{}, OAuthAuthError + } else if googAccount.Error.StatusCode >= 400 { + return googleAccount{}, OAuthError(googAccount.Error.Message) + } + } + // stop instrumentation + return googAccount, nil +} + +func (r *RequestBundle) getAccountByForeignID(foreign_id string) (Account, error) { + // start instrumentation + reply := r.Repo.client.Hget("oauth_foreign_ids_to_accounts", foreign_id) + // report the request to the repo to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return Account{}, reply.Err + } + if reply.Type == redis.ReplyNil { + r.Log.Warn("Account not found. Foreign ID: %s", foreign_id) + return Account{}, nil + } + account_id, err := reply.Str() + if err != nil { + r.Log.Error(err.Error()) + return Account{}, nil + } + id, err := ruid.RUIDFromString(account_id) + if err != nil { + r.Log.Error(err.Error()) + return Account{}, err + } + account, err := r.GetAccountByID(id) + // report the request to the repo for instrumentation + if err != nil { + r.Log.Error(err.Error()) + return Account{}, err + } + // stop instrumentation + return account, nil +} + +func (r *RequestBundle) GetAccountByID(id ruid.RUID) (Account, error) { + // start instrumentation + reply := r.Repo.client.Hgetall("accounts:" + id.String()) + // report the request to the repo to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return Account{}, reply.Err + } + if reply.Type == redis.ReplyNil { + r.Log.Warn("Account not found. ID: %s", id) + return Account{}, nil + } + hash, err := reply.Hash() + if err != nil { + r.Log.Error(err.Error()) + return Account{}, err + } + added, err := time.Parse(time.RFC3339, hash["added"]) + if err != nil { + r.Log.Error(err.Error()) + return Account{}, err + } + expires, err := time.Parse(time.RFC3339, hash["expires"]) + if err != nil { + r.Log.Error(err.Error()) + return Account{}, err + } + user_id, err := ruid.RUIDFromString(hash["user_id"]) + if err != nil { + return Account{}, err + } + account := Account{ + Added: added, + ID: id, + Provider: hash["provider"], + ForeignID: hash["foreign_id"], + Email: hash["email"], + EmailVerified: hash["email_verified"] == "1", + DisplayName: hash["display_name"], + GivenName: hash["given_name"], + FamilyName: hash["family_name"], + Picture: hash["picture"], + Locale: hash["locale"], + Timezone: hash["timezone"], + Gender: hash["gender"], + UserID: user_id, + accessToken: hash["access_token"], + refreshToken: hash["refresh_token"], + expires: expires, + } + // stop instrumentation + return account, nil +} + +func (r *RequestBundle) storeAccount(account Account, update bool) error { + // start instrumentation + if update { + changes := map[string]interface{}{} + from := map[string]interface{}{} + old_account, err := r.GetAccountByID(account.ID) + // report the repo request to instrumentation + if err != nil { + r.Log.Error(err.Error()) + return err + } + if old_account.Email != account.Email { + changes["email"] = account.Email + from["email"] = old_account.Email + } + if old_account.EmailVerified != account.EmailVerified { + changes["email_verified"] = account.EmailVerified + from["email_verified"] = old_account.EmailVerified + } + if old_account.DisplayName != account.DisplayName { + changes["display_name"] = account.DisplayName + from["display_name"] = old_account.DisplayName + } + if old_account.GivenName != account.GivenName { + changes["given_name"] = account.GivenName + from["given_name"] = old_account.GivenName + } + if old_account.FamilyName != account.FamilyName { + changes["family_name"] = account.FamilyName + from["family_name"] = old_account.FamilyName + } + if old_account.Picture != account.Picture { + changes["picture"] = account.Picture + from["picture"] = old_account.Picture + } + if old_account.Locale != account.Locale { + changes["locale"] = account.Locale + from["locale"] = old_account.Locale + } + if old_account.Timezone != account.Timezone { + changes["timezone"] = account.Timezone + from["timezone"] = old_account.Timezone + } + if old_account.Gender != account.Gender { + changes["gender"] = account.Gender + from["gender"] = old_account.Gender + } + if old_account.UserID != account.UserID { + changes["user_id"] = account.UserID.String() + from["user_id"] = old_account.UserID.String() + } + if old_account.accessToken != account.accessToken { + changes["access_token"] = account.accessToken + from["access_token"] = old_account.accessToken + } + if old_account.refreshToken != account.refreshToken { + changes["refresh_token"] = account.refreshToken + from["refresh_token"] = old_account.refreshToken + } + if !old_account.expires.Equal(account.expires) { + changes["expires"] = account.expires.Format(time.RFC3339) + from["expires"] = old_account.expires.Format(time.RFC3339) + } + reply := r.Repo.client.Hmset("accounts:"+account.ID.String(), changes) + // add repo call to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return reply.Err + } + + reply = r.Repo.client.Sadd("users:"+account.UserID.String()+":accounts", account.ID.String()) + // add repo call to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return reply.Err + } + r.AuditMap("accounts:"+account.ID.String(), from, changes) + // add repo call to instrumentation + return nil + } + changes := map[string]interface{}{ + "added": account.Added.Format(time.RFC3339), + "provider": account.Provider, + "foreign_id": account.ForeignID, + "email": account.Email, + "email_verified": account.EmailVerified, + "display_name": account.DisplayName, + "given_name": account.GivenName, + "family_name": account.FamilyName, + "picture": account.Picture, + "locale": account.Locale, + "timezone": account.Timezone, + "gender": account.Gender, + "user_id": account.UserID.String(), + "access_token": account.accessToken, + "refresh_token": account.refreshToken, + "expires": account.expires.Format(time.RFC3339), + } + from := map[string]interface{}{ + "added": "", + "provider": "", + "foreign_id": "", + "email": "", + "email_verified": "", + "display_name": "", + "given_name": "", + "family_name": "", + "picture": "", + "locale": "", + "timezone": "", + "gender": "", + "user_id": "", + "access_token": "", + "refresh_token": "", + "expires": "", + } + reply := r.Repo.client.MultiCall(func(mc *redis.MultiCall) { + mc.Hmset("accounts:"+account.ID.String(), changes) + mc.Hmset("oauth_foreign_ids_to_accounts", account.ForeignID, account.ID.String()) + mc.Sadd("users:"+account.UserID.String()+":accounts", account.ID.String()) + }) + // add repo call to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return reply.Err + } + r.AuditMap("accounts:"+account.ID.String(), from, changes) + r.Audit("oauth_foreign_ids_to_accounts", account.ForeignID, "", account.ID.String()) + // report the repo request to instrumentation + // stop instrumentation + return nil +} + +func (r *RequestBundle) GetAccountsByUser(user User) ([]Account, error) { + // start instrumentation + reply := r.Repo.client.Smembers("users:" + user.ID.String() + ":accounts") + // report the repo request to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return []Account{}, reply.Err + } + if reply.Type == redis.ReplyNil { + r.Log.Warn("User accounts not found. User ID: %s", user.ID) + return []Account{}, nil + } + ids, err := reply.List() + if err != nil { + r.Log.Error(err.Error()) + return []Account{}, err + } + reply = r.Repo.client.MultiCall(func(mc *redis.MultiCall) { + for _, id := range ids { + mc.Hgetall("accounts:" + id) + } + }) + // report the repo call to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return []Account{}, reply.Err + } + accounts := []Account{} + for pos, elem := range reply.Elems { + if elem.Type == redis.ReplyNil { + r.Log.Warn("Account not found: %s", ids[pos]) + continue + } + hash, err := elem.Hash() + if err != nil { + r.Log.Error(err.Error()) + continue + } + added, err := time.Parse(time.RFC3339, hash["added"]) + if err != nil { + r.Log.Error(err.Error()) + continue + } + expires, err := time.Parse(time.RFC3339, hash["expires"]) + if err != nil { + r.Log.Error(err.Error()) + continue + } + user_id, err := ruid.RUIDFromString(hash["user_id"]) + if err != nil { + r.Log.Error(err.Error()) + continue + } + id, err := ruid.RUIDFromString(ids[pos]) + if err != nil { + r.Log.Error(err.Error()) + continue + } + account := Account{ + Added: added, + ID: id, + Provider: hash["provider"], + ForeignID: hash["foreign_id"], + Email: hash["email"], + EmailVerified: hash["email_verified"] == "1", + DisplayName: hash["display_name"], + GivenName: hash["given_name"], + FamilyName: hash["family_name"], + Picture: hash["picture"], + Locale: hash["locale"], + Timezone: hash["timezone"], + Gender: hash["gender"], + UserID: user_id, + accessToken: hash["access_token"], + refreshToken: hash["refresh_token"], + expires: expires, + } + accounts = append(accounts, account) + } + // stop instrumentation + return accounts, nil +} + +func (r *RequestBundle) UpdateAccountTokens(account Account, access, refresh string, expires time.Time) error { + // start instrumentation + account.accessToken = access + account.refreshToken = refresh + account.expires = expires + err := r.storeAccount(account, true) + // report the repo request to instrumentation + if err != nil { + return err + } + // stop instrumentation + return nil +} + +func (r *RequestBundle) UpdateAccountData() error { + // start instrumentation + // report the repo request to instrumentation + // log the changes to the audit log + // report the repo request to instrumentation + // stop instrumentation + return nil +} + +func (r *RequestBundle) AssociateUserWithAccount(account Account, user ruid.RUID) error { + // begin instrumentation + account.UserID = user + err := r.storeAccount(account, true) + // report repo request to instrumentation + if err != nil { + r.Log.Error(err.Error()) + return err + } + // stop instrumentation + return nil +} + +func (r *RequestBundle) DeleteAccount(account Account) error { + // start instrumentation + // report the repo request to instrumentation + // log the changes to the audit log + // report the repo request to instrumentation + // stop instrumentation + return nil +} + +func (r *RequestBundle) DeleteAccounts(user User) error { + // start instrumentation + // report the repo request to instrumentation + // log the changes to the audit log + // report the repo request to instrumentation + // stop instrumentation + return nil +} diff --git a/audit.go b/audit.go new file mode 100644 index 0000000..4287e41 --- /dev/null +++ b/audit.go @@ -0,0 +1,86 @@ +package twocloud + +import ( + "github.com/fzzbt/radix/redis" + "secondbit.org/ruid" + "time" +) + +type Auditor struct { + client *redis.Client +} + +func NewAuditor(conf redis.Config) *Auditor { + return &Auditor{ + client: redis.NewClient(conf), + } +} + +func (a *Auditor) Close() { + a.client.Close() +} + +type Change struct { + ID ruid.RUID `json:"id"` + Key string `json:"key"` + Field string `json:"field"` + From interface{} `json:"from"` + To interface{} `json:"to"` + IP string `json:"ip"` + User User `json:"user"` + Timestamp time.Time `json:"timestamp"` +} + +func (a *Auditor) Insert(key, ip string, user User, from, to map[string]interface{}) error { + changes := []Change{} + for k, v := range to { + id, err := gen.Generate([]byte(key)) + if err != nil { + return err + } + change := Change{ + ID: id, + Key: key, + From: from[k], + To: v, + Field: k, + Timestamp: time.Now(), + IP: ip, + User: user, + } + changes = append(changes, change) + } + reply := a.client.MultiCall(func(mc *redis.MultiCall) { + for _, change := range changes { + user_str := "" + if change.User.ID != ruid.RUID(0) { + user_str = change.User.ID.String() + } + mc.Hmset("audit:"+change.Key+":item:"+change.ID.String(), "from", change.From, "to", change.To, "field", change.Field, "timestamp", change.Timestamp.Format(time.RFC3339), "user", user_str) + mc.Lpush("audit:"+key, change.ID.String()) + } + }) + return reply.Err +} + +func (r *RequestBundle) Audit(key, field, fromstr, tostr string) { + if r.Auditor != nil { + from := map[string]interface{}{} + from[field] = fromstr + to := map[string]interface{}{} + to[field] = tostr + err := r.Auditor.Insert(key, r.Request.RemoteAddr, r.AuthUser, from, to) + if err != nil { + r.Log.Error(err.Error()) + } + } +} + +func (r *RequestBundle) AuditMap(key string, from, to map[string]interface{}) { + if r.Auditor != nil { + err := r.Auditor.Insert(key, r.Request.RemoteAddr, r.AuthUser, from, to) + if err != nil { + r.Log.Error(err.Error()) + } + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..f95444b --- /dev/null +++ b/config.go @@ -0,0 +1,20 @@ +package twocloud + +import ( + "github.com/fzzbt/radix/redis" +) + +type Config struct { + UseSubscriptions bool `json:"subscriptions"` + MaintenanceMode bool `json:"maintenance"` + Database redis.Config `json:"db"` + AuditDatabase redis.Config `json:"audit_db"` + StatsDatabase redis.Config `json:"stats_db"` + OAuth OAuthClient `json:"oauth"` +} + +type OAuthClient struct { + ClientID string + ClientSecret string + CallbackURL string +} diff --git a/device.go b/device.go new file mode 100644 index 0000000..40084c8 --- /dev/null +++ b/device.go @@ -0,0 +1,4 @@ +package twocloud + +type Device struct { +} diff --git a/instrumentation.go.stub b/instrumentation.go.stub new file mode 100644 index 0000000..e69de29 diff --git a/link.go b/link.go new file mode 100644 index 0000000..9511cfb --- /dev/null +++ b/link.go @@ -0,0 +1,4 @@ +package twocloud + +type Link struct { +} diff --git a/log.go b/log.go new file mode 100644 index 0000000..ea77e24 --- /dev/null +++ b/log.go @@ -0,0 +1,56 @@ +package twocloud + +import ( + "io/ioutil" + "log" + "os" +) + +type Log struct { + logger *log.Logger + logLevel logLevel +} + +type logLevel int + +const ( + LogLevelDebug = logLevel(iota) + LogLevelWarn + LogLevelError +) + +func (l *Log) Debug(format string, v ...interface{}) { + if l.logLevel <= LogLevelDebug { + l.logger.Printf(format, v...) + } +} + +func (l *Log) Warn(format string, v ...interface{}) { + if l.logLevel <= LogLevelWarn { + l.logger.Printf(format, v...) + } +} + +func (l *Log) Error(format string, v ...interface{}) { + if l.logLevel <= LogLevelError { + l.logger.Printf(format, v...) + } +} + +func (l *Log) SetLogLevel(level logLevel) { + l.logLevel = level +} + +func StdOutLogger(level logLevel) *Log { + return &Log { + logger: log.New(os.Stdout, "2cloud", log.LstdFlags | log.Llongfile), + logLevel: level, + } +} + +func NullLogger() *Log { + return &Log { + logger: log.New(ioutil.Discard, "2cloud", log.LstdFlags), + logLevel: LogLevelError, + } +} diff --git a/notification.go b/notification.go new file mode 100644 index 0000000..6cca62c --- /dev/null +++ b/notification.go @@ -0,0 +1,4 @@ +package twocloud + +type Notification struct { +} diff --git a/radix.go b/radix.go new file mode 100644 index 0000000..4e7cd6b --- /dev/null +++ b/radix.go @@ -0,0 +1,19 @@ +package twocloud + +import ( + "github.com/fzzbt/radix/redis" +) + +type Radix struct { + client *redis.Client +} + +func NewRadix(conf redis.Config) *Radix { + return &Radix{ + client: redis.NewClient(conf), + } +} + +func (r *Radix) Close() { + r.client.Close() +} diff --git a/stats.go.stub b/stats.go.stub new file mode 100644 index 0000000..e69de29 diff --git a/twocloud.go b/twocloud.go new file mode 100644 index 0000000..a0b662b --- /dev/null +++ b/twocloud.go @@ -0,0 +1,30 @@ +package twocloud + +import ( + "net/http" + "secondbit.org/ruid" + "time" +) + +var gen *ruid.Generator + +func init() { + location, err := time.LoadLocation("America/New_York") + if err != nil { + panic(err.Error()) + } + epoch := time.Date(2010, time.December, 2, 0, 0, 0, 0, location) + gen = ruid.NewGenerator(epoch) +} + +type RequestBundle struct { + Repo *Radix + Config Config + Log *Log + // Cache + Auditor *Auditor + // Instrumentor + // Instrument + Request *http.Request + AuthUser User +} diff --git a/user.go b/user.go new file mode 100644 index 0000000..369aabd --- /dev/null +++ b/user.go @@ -0,0 +1,751 @@ +package twocloud + +import ( + crypto "crypto/rand" + "encoding/hex" + "errors" + "github.com/fzzbt/radix/redis" + "io" + "math/rand" + "secondbit.org/ruid" + "strings" + "time" +) + +type Name struct { + Given string `json:"given,omitempty"` + Family string `json:"family,omitempty"` +} + +type User struct { + ID ruid.RUID `json:"id,omitempty"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + EmailUnconfirmed bool `json:"email_unconfirmed,omitempty"` + EmailConfirmation string `json:"-"` + Secret string `json:"secret,omitempty"` + Joined time.Time `json:"joined,omitempty"` + Name Name `json:"name,omitempty"` + LastActive time.Time `json:"last_active,omitempty"` + IsAdmin bool `json:"is_admin,omitempty"` + Subscription *Subscription `json:"subscription,omitempty"` +} + +type Subscription struct { + ID string `json:"-"` + Active bool `json:"active"` + InGracePeriod bool `json:"in_grace_period"` + Expires time.Time `json:"expires"` +} + +func GenerateTempCredentials() string { + cred := "" + acceptableChars := [50]string{"a", "b", "c", "d", "e", "f", "g", "h", "j", "k", "m", "n", "p", "q", "r", "s", "t", "w", "x", "y", "z", "A", "B", "C", "D", "E", "F", "G", "H", "J", "K", "M", "N", "P", "Q", "R", "S", "T", "W", "X", "Y", "Z", "2", "3", "4", "5", "6", "7", "8", "9"} + for i := 0; i < 5; i++ { + rand.Seed(time.Now().UnixNano()) + cred = cred + acceptableChars[rand.Intn(50)] + } + return cred +} + +func GenerateSecret() (string, error) { + secret := make([]byte, 64) + _, err := io.ReadFull(crypto.Reader, secret) + if err != nil { + return "", err + } + return hex.EncodeToString(secret), nil +} + +func GenerateEmailConfirmation() (string, error) { + code := make([]byte, 32) + _, err := io.ReadFull(crypto.Reader, code) + if err != nil { + return "", err + } + return hex.EncodeToString(code), nil +} + +var InvalidCredentialsError = errors.New("The credentials entered were not valid.") +var InvalidConfirmationCodeError = errors.New("The confirmation code entered was not valid.") +var UsernameTakenError = errors.New("That username is already in use. Please select another.") +var InvalidUsernameCharacterError = errors.New("An invalid character was used in the username. Only a-z, A-Z, 0-9, -, and _ are allowed in usernames.") +var InvalidUsernameLengthError = errors.New("Your username must be between 3 and 20 characters long.") +var MissingEmailError = errors.New("No email address was supplied. An email address is required.") +var UserNotFoundError = errors.New("User was not found in the database.") + +type SubscriptionExpiredError struct { + Expired time.Time +} + +func (e *SubscriptionExpiredError) Error() string { + specifics := "" + if !e.Expired.IsZero() { + specifics = " It expired on " + e.Expired.Format("%B %d, %Y") + "." + } + return "Your subscription has expired." + specifics +} + +type SubscriptionExpiredWarning struct { + Expired time.Time +} + +func (e *SubscriptionExpiredWarning) Error() string { + specifics := "" + if !e.Expired.IsZero() { + specifics = " It expired on " + e.Expired.Format("%B %d, %Y") + ". You have until " + e.Expired.Format("%B %d, %Y") + " to renew it, or lose access to your account." + } + return "Warning! Your subscription has expired." + specifics +} + +func ValidateUsername(username string) error { + if len(username) < 3 || len(username) > 20 { + return InvalidUsernameLengthError + } + asciiOnly := func(r rune) rune { + switch { + case r >= 'A' && r <= 'Z': + return r + case r >= 'a' && r <= 'z': + return r + case r >= '0' && r <= '9': + return r + case r == '-' || r == '_': + return r + default: + return -1 + } + return -1 + } + newUsername := strings.Map(asciiOnly, username) + if username != newUsername { + return InvalidUsernameCharacterError + } + return nil +} + +func (r *RequestBundle) Authenticate(username, secret string) (User, error) { + // start instrumentation + id, err := r.GetUserID(username) + if err != nil { + return User{}, err + } + // add cache/repo calls to instrumentation + user, err := r.GetUser(id) + if err != nil { + return User{}, err + } + // add repo calls to instrumentation + if user.Secret != secret { + r.Log.Warn("Invalid auth attempt for %s's account.", username) + // report invalid auth attempt to stats + return User{}, InvalidCredentialsError + } + err = r.updateUserLastActive(id) + if err != nil { + r.Log.Error(err.Error()) + } + // add repo call to instrumentation + // report user activity to stats + // add repo calls to instrumentation + var subscriptionError error + if r.Config.UseSubscriptions { + if !user.Subscription.Active && !user.IsAdmin { + if !user.Subscription.InGracePeriod { + subscriptionError = &SubscriptionExpiredError{Expired: user.Subscription.Expires} + } else { + subscriptionError = &SubscriptionExpiredWarning{Expired: user.Subscription.Expires} + } + } + } + // store instrumentation + return user, subscriptionError +} + +func (r *RequestBundle) updateUserLastActive(id ruid.RUID) error { + // start instrumentation + reply := r.Repo.client.MultiCall(func(mc *redis.MultiCall) { + mc.Hset("users:"+id.String(), "last_active", time.Now().Format(time.RFC3339)) + mc.Zadd("users_by_last_active", time.Now().Unix(), id.String()) + }) + // report repo call to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return reply.Err + } + // stop instrumentation + return nil +} + +func (r *RequestBundle) Register(username, email, given_name, family_name string, email_unconfirmed, is_admin bool) (User, error) { + // start instrumentation + email = strings.TrimSpace(email) + username = strings.TrimSpace(username) + given_name = strings.TrimSpace(given_name) + family_name = strings.TrimSpace(family_name) + err := ValidateUsername(username) + if err != nil { + r.Log.Error(err.Error()) + return User{}, err + } + if email == "" { + return User{}, MissingEmailError + } + id, err := gen.Generate([]byte(username)) + if err != nil { + r.Log.Error(err.Error()) + return User{}, err + } + secret, err := GenerateSecret() + if err != nil { + r.Log.Error(err.Error()) + return User{}, err + } + code, err := GenerateEmailConfirmation() + if err != nil { + r.Log.Error(err.Error()) + return User{}, err + } + success, err := r.reserveUsername(username, id) + // add repo call to instrumentation + if err != nil { + return User{}, err + } + if !success { + return User{}, UsernameTakenError + } + // add repo calls to instrumentation + user := User{ + ID: id, + Username: username, + Email: email, + EmailUnconfirmed: email_unconfirmed, + EmailConfirmation: code, + Secret: secret, + Joined: time.Now(), + Name: Name{ + Given: given_name, + Family: family_name, + }, + LastActive: time.Now(), + IsAdmin: is_admin, + Subscription: &Subscription{ + InGracePeriod: true, + }, + } + err = r.storeUser(user, false) + // add repo calls to instrumentation + if err != nil { + release_err := r.releaseUsername(username) + if release_err != nil { + r.Log.Error(release_err.Error()) + } + // add repo call to instrumentation + r.Log.Error(err.Error()) + return User{}, err + } + // log the user registration in stats + // add repo calls to instrumentation + // send the confirmation email + // stop instrumentation + return user, nil +} + +func (r *RequestBundle) reserveUsername(username string, id ruid.RUID) (bool, error) { + // start instrumentation + reply := r.Repo.client.Hsetnx("usernames_to_ids", strings.ToLower(username), id.String()) + // report repo call to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return false, reply.Err + } + r.Audit("usernames_to_ids", strings.ToLower(username), "", id.String()) + // report repo calls to instrumentation + // stop instrumentation + return reply.Bool() +} + +func (r *RequestBundle) releaseUsername(username string) error { + // start instrumentation + reply := r.Repo.client.Hget("usernames_to_ids", strings.ToLower(username)) + // report the repo call to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return reply.Err + } + if reply.Type == redis.ReplyNil { + return nil + } + was, err := reply.Str() + if err != nil { + r.Log.Error(err.Error()) + return err + } + reply = r.Repo.client.Hdel("usernames_to_ids", strings.ToLower(username)) + // report the repo call to instrumentation + if reply.Err != nil { + r.Log.Error(err.Error()) + return reply.Err + } + r.Audit("usernames_to_ids", strings.ToLower(username), was, "") + // report repo calls to instrumentation + // stop instrumentation + return nil +} + +func (r *RequestBundle) storeUser(user User, update bool) error { + // start instrumentation + if update { + changes := map[string]interface{}{} + from := map[string]interface{}{} + old_user, err := r.GetUser(user.ID) + // add repo call to instrumentation + if err != nil { + return err + } + if old_user.Email != user.Email { + changes["email"] = user.Email + from["email"] = old_user.Email + } + if old_user.EmailConfirmation != user.EmailConfirmation { + changes["email_confirmation"] = user.EmailConfirmation + from["email_confirmation"] = old_user.EmailConfirmation + } + if old_user.EmailUnconfirmed != user.EmailUnconfirmed { + changes["email_unconfirmed"] = user.EmailUnconfirmed + from["email_unconfirmed"] = old_user.EmailUnconfirmed + } + if old_user.IsAdmin != user.IsAdmin { + changes["is_admin"] = user.IsAdmin + from["is_admin"] = old_user.IsAdmin + } + if old_user.Name.Family != user.Name.Family { + changes["family_name"] = user.Name.Family + from["family_name"] = old_user.Name.Family + } + if old_user.Name.Given != user.Name.Given { + changes["given_name"] = user.Name.Given + from["given_name"] = old_user.Name.Given + } + reply := r.Repo.client.MultiCall(func(mc *redis.MultiCall) { + mc.Hmset("users:"+user.ID.String(), changes) + val, set := changes["email_confirmation"] + if val.(bool) && set { + mc.Sadd("unconfirmed_emails", user.ID.String()) + } + }) + // add repo call to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return reply.Err + } + r.AuditMap("users:"+user.ID.String(), from, changes) + // add repo call to instrumentation + return nil + } + changes := map[string]interface{}{ + "username": user.Username, + "email": user.Email, + "email_unconfirmed": user.EmailUnconfirmed, + "email_confirmation": user.EmailConfirmation, + "secret": user.Secret, + "joined": user.Joined.Format(time.RFC3339), + "given_name": user.Name.Given, + "family_name": user.Name.Family, + "last_active": user.LastActive.Format(time.RFC3339), + "is_admin": user.IsAdmin, + "subscription_id": user.Subscription.ID, + "subscription_expires": user.Subscription.Expires.Format(time.RFC3339), + "subscription_active": user.Subscription.Active, + "subscription_igp": user.Subscription.InGracePeriod, + } + from := map[string]interface{}{ + "username": "", + "email": "", + "email_unconfirmed": "", + "email_confirmation": "", + "secret": "", + "joined": "", + "given_name": "", + "family_name": "", + "last_active": "", + "is_admin": "", + "subscription_id": "", + "subscription_expires": "", + "subscription_active": "", + "subscription_igp": "", + } + reply := r.Repo.client.MultiCall(func(mc *redis.MultiCall) { + mc.Hmset("users:"+user.ID.String(), changes) + mc.Zadd("users_by_join_date", user.Joined.Unix(), user.ID.String()) + mc.Sadd("unconfirmed_emails", user.ID.String()) + }) + // add repo call to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return reply.Err + } + r.AuditMap("users:"+user.ID.String(), from, changes) + // add repo call to instrumentation + // stop instrumentation + return nil +} + +func (r *RequestBundle) GetUser(id ruid.RUID) (User, error) { + // start instrumentation + reply := r.Repo.client.Hgetall("users:" + id.String()) + // add repo call to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return User{}, reply.Err + } + if reply.Type == redis.ReplyNil { + return User{}, UserNotFoundError + } + hash, err := reply.Hash() + if err != nil { + r.Log.Error(err.Error()) + return User{}, err + } + joined, err := time.Parse(time.RFC3339, hash["joined"]) + if err != nil { + r.Log.Error(err.Error()) + return User{}, err + } + last_active, err := time.Parse(time.RFC3339, hash["last_active"]) + if err != nil { + r.Log.Error(err.Error()) + return User{}, err + } + subscription_expires, err := time.Parse(time.RFC3339, hash["subscription_expires"]) + if err != nil { + r.Log.Error(err.Error()) + return User{}, err + } + user := User{ + ID: id, + Username: hash["username"], + Email: hash["email"], + EmailUnconfirmed: hash["email_unconfirmed"] == "1", + EmailConfirmation: hash["email_confirmation"], + Secret: hash["secret"], + Joined: joined, + Name: Name{ + Given: hash["given_name"], + Family: hash["family_name"], + }, + LastActive: last_active, + IsAdmin: hash["is_admin"] == "1", + Subscription: &Subscription{ + Expires: subscription_expires, + ID: hash["subscription_id"], + Active: hash["subscription_active"] == "1", + InGracePeriod: hash["subscription_igp"] == "1", + }, + } + // stop instrumentation + return user, nil +} + +func (r *RequestBundle) GetUserID(username string) (ruid.RUID, error) { + var idstr string + var err error + // start instrumentation + // check cache for user id + // add cache check to instrumentation + // if cached, return id + reply := r.Repo.client.Hget("usernames_to_ids", strings.ToLower(username)) + // add repo call to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return ruid.RUID(0), reply.Err + } + if reply.Type == redis.ReplyNil { + return ruid.RUID(0), UserNotFoundError + } + idstr, err = reply.Str() + if err != nil { + r.Log.Error(err.Error()) + return ruid.RUID(0), err + } + // cache the user id + // add cache request to instrumentation + id, err := ruid.RUIDFromString(idstr) + if err != nil { + r.Log.Error(err.Error()) + return ruid.RUID(0), err + } + // stop instrumentation + return id, nil +} + +func (r *RequestBundle) GetUsersByActivity(count int, active_after, active_before time.Time) ([]User, error) { + // redis key users_by_last_active + return []User{}, nil +} + +func (r *RequestBundle) GetUsersByJoinDate(count int, joined_after, joined_before time.Time) ([]User, error) { + // redis key users_by_join_date + return []User{}, nil +} + +func (r *RequestBundle) GetUsersByUnconfirmedEmail(count int) ([]User, error) { + // redis key unconfirmed_emails + return []User{}, nil +} + +func (r *RequestBundle) UpdateUser(user User, email, given_name, family_name string, name_changed bool) error { + // start instrumentation + email = strings.TrimSpace(email) + given_name = strings.TrimSpace(given_name) + family_name = strings.TrimSpace(family_name) + email_changed := false + if email != "" { + code, err := GenerateEmailConfirmation() + if err != nil { + r.Log.Error(err.Error()) + return err + } + user.EmailConfirmation = code + user.EmailUnconfirmed = true + user.Email = email + email_changed = true + } + if name_changed { + user.Name.Given = given_name + user.Name.Family = family_name + } + err := r.storeUser(user, true) + // add repo request to instrumentation + if err != nil { + return err + } + if email_changed { + // send the confirmation email + } + // send the push notification + // stop the instrumentation + return nil +} + +func (r *RequestBundle) VerifyEmail(user User, code string) error { + // start instrumentation + if !user.EmailUnconfirmed { + // return an error + } + if user.EmailConfirmation != code { + return InvalidConfirmationCodeError + } + user.EmailUnconfirmed = false + err := r.storeUser(user, true) + // add the repo request to instrumentation + if err != nil { + return err + } + // log the verified email in stats + // send the push notification + // stop instrumentation + return nil +} + +func (r *RequestBundle) MakeAdmin(user User) error { + // start instrumentation + user.IsAdmin = true + err := r.storeUser(user, true) + // add the repo request to instrumentation + if err != nil { + return err + } + // send the push notification + // stop instrumentation + return nil +} + +func (r *RequestBundle) StripAdmin(user User) error { + // start instrumentation + user.IsAdmin = false + err := r.storeUser(user, true) + // add the repo request to instrumentation + if err != nil { + return err + } + // send the push notification + // stop instrumentation + return nil +} + +func (r *RequestBundle) CreateTempCredentials(user User) ([2]string, error) { + // start instrumentation + tmpcred1 := GenerateTempCredentials() + tmpcred2 := GenerateTempCredentials() + cred1 := tmpcred1 + cred2 := tmpcred2 + if tmpcred1 > tmpcred2 { + cred1 = tmpcred2 + cred2 = tmpcred1 + } + reply := r.Repo.client.MultiCall(func(mc *redis.MultiCall) { + mc.Set("tokens:"+user.ID.String()+":"+cred1, cred2) + mc.Expire("tokens:"+user.ID.String()+":"+cred1, "300") + }) + // add the repo request to instrumentation + if reply.Err != nil { + return [2]string{"", ""}, reply.Err + } + for _, rep := range reply.Elems { + if rep.Err != nil { + return [2]string{"", ""}, rep.Err + } + } + r.Audit("tokens:"+user.ID.String(), cred1, "", cred2) + // add the repo requests to instrumentation + return [2]string{cred1, cred2}, nil +} + +func (r *RequestBundle) CheckTempCredentials(id ruid.RUID, cred1, cred2 string) (bool, error) { + // start instrumentation + firstcred := cred1 + secondcred := cred2 + if firstcred > secondcred { + firstcred = cred2 + secondcred = cred1 + } + reply := r.Repo.client.Get("tokens:" + id.String() + ":" + firstcred) + // add the repo request to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return false, reply.Err + } + if reply.Type == redis.ReplyNil { + // add invalid credential error to stats + // add the repo requests to instrumentation + return false, InvalidCredentialsError + } + val, err := reply.Str() + if err != nil { + r.Log.Error(err.Error()) + return false, err + } + return val == secondcred, nil + // stop instrumentation +} + +func (r *RequestBundle) DeleteUser(user User) error { + // start instrumentation + // delete the user from the repo + // add the repo request to instrumentation + // clear the username from cache + // add the cache request to instrumentation + // log the deletion in the audit log + // add the repo requests to instrumentation + // cascade that deletion to other models + // add the repo requests to instrumentation + // log the deletion in stats + // add the repo requests to instrumentation + // send the push notification + // stop instrumentation + return nil +} + +func (r *RequestBundle) UpdateSubscription(user User, expires time.Time) error { + // start instrumentation + user.Subscription.Expires = expires + active := expires.After(time.Now()) + grace := expires.Add(time.Hour * 24 * 7) + igp := !active && grace.After(time.Now()) + if !user.Subscription.InGracePeriod && igp { + user.Subscription.InGracePeriod = true + } else if user.Subscription.InGracePeriod && !igp { + user.Subscription.InGracePeriod = false + } + if !user.Subscription.Active && active { + user.Subscription.Active = true + } else if !active && user.Subscription.Active { + user.Subscription.Active = false + } + err := r.storeSubscription(user.ID, user.Subscription) + // add repo request to instrumentation + if err != nil { + return err + } + // send the push notification + // stop instrumentation + return nil +} + +func (r *RequestBundle) UpdateSubscriptionStatus(user User) error { + // start instrumentation + if user.Subscription.Expires.After(time.Now()) { + if !user.Subscription.Active { + user.Subscription.Active = true + } + if user.Subscription.InGracePeriod { + user.Subscription.InGracePeriod = false + } + err := r.storeSubscription(user.ID, user.Subscription) + // add the repo request to instrumentation + if err != nil { + return err + } + // stop instrumentation + return nil + } + grace := user.Subscription.Expires.Add(time.Hour * 24 * 7) + if user.Subscription.Active { + user.Subscription.Active = false + } + if !user.Subscription.InGracePeriod { + if grace.After(time.Now()) { + user.Subscription.InGracePeriod = true + } + } else { + if time.Now().After(grace) { + user.Subscription.InGracePeriod = false + } + } + err := r.storeSubscription(user.ID, user.Subscription) + // add the repo request to instrumentation + if err != nil { + return err + } + // send the push notification + // stop instrumentation + return nil +} + +func (r *RequestBundle) storeSubscription(userID ruid.RUID, subscription *Subscription) error { + // start instrumentation + changes := map[string]interface{}{} + from := map[string]interface{}{} + old_user, err := r.GetUser(userID) + // add repo call to instrumentation + if err != nil { + return err + } + old_sub := old_user.Subscription + if old_sub.Active != subscription.Active { + changes["subscription_active"] = subscription.Active + from["subscription_active"] = old_sub.Active + } + if old_sub.InGracePeriod != subscription.InGracePeriod { + changes["subscription_igp"] = subscription.InGracePeriod + from["subscription_igp"] = old_sub.InGracePeriod + } + if old_sub.Expires != subscription.Expires { + changes["subscription_expires"] = subscription.Expires.Format(time.RFC3339) + from["subscription_expires"] = old_sub.Expires.Format(time.RFC3339) + } + if old_sub.ID != subscription.ID { + changes["subscription_id"] = subscription.ID + from["subscription_id"] = old_sub.ID + } + reply := r.Repo.client.Hmset("users:"+userID.String(), changes) + // add repo call to instrumentation + if reply.Err != nil { + r.Log.Error(reply.Err.Error()) + return reply.Err + } + r.AuditMap("users:"+userID.String(), from, changes) + // stop instrumentation + return nil +}