Files
services/user/domain/domain.go
Daniel Joudat 5220d7fade fix an error when calling VerifyToken with valid Token (#327)
* add two rpcs to User service:
	- Passwordless: endpoint that receives an email, topic and an optional message
	- PasswordlessML: endpoint that receives token and topic via MagicLink.

* Proposal to add Passwordless login feature to the endpoint user.

* remove currency run check

* Commit from GitHub Actions (Publish APIs & Clients)

* Create downstream.yml

* Commit from GitHub Actions (Publish APIs & Clients)

* update todo

* Commit from GitHub Actions (Publish APIs & Clients)

* Update publish.yml

* Commit from GitHub Actions (Publish APIs & Clients)

* Update publish.yml

* Commit from GitHub Actions (Publish APIs & Clients)

* Update and rename publish.yml to generate.yml

* Update generate.yml

* Commit from GitHub Actions (Generate Clients & Examples)

* Commit from GitHub Actions (Generate Clients & Examples)

* add comments

* Commit from GitHub Actions (Generate Clients & Examples)

* move otp to auth category

* charge for user verification

* Commit from GitHub Actions (Generate Clients & Examples)

* Update user.proto

* Commit from GitHub Actions (Generate Clients & Examples)

* Commit from GitHub Actions (Generate Clients & Examples)

* Change js client git repo url (#249)

* Commit from GitHub Actions (Generate Clients & Examples)

* use tableName func for Count

* Commit from GitHub Actions (Generate Clients & Examples)

* update notes example

* Commit from GitHub Actions (Generate Clients & Examples)

* Update .gitignore

* Update .gitignore

* Commit from GitHub Actions (Generate Clients & Examples)

* add new endpoints SendMagicLink and VerifyToken to M3O user serivce

Signed-off-by: Daniel Joudat <danieljoudat@gmail.com>

* fix an error in user.VerifyToken

Signed-off-by: Daniel Joudat <danieljoudat@gmail.com>

* OSD add another check for err in user CacheReadToken

Signed-off-by: Daniel Joudat <danieljoudat@gmail.com>

Co-authored-by: Asim Aslam <asim@aslam.me>
Co-authored-by: asim <asim@users.noreply.github.com>
Co-authored-by: Janos Dobronszki <dobronszki@gmail.com>
Co-authored-by: crufter <crufter@users.noreply.github.com>
2021-12-13 18:47:24 +00:00

514 lines
12 KiB
Go

package domain
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"path"
"strings"
"time"
_struct "github.com/golang/protobuf/ptypes/struct"
"github.com/micro/micro/v3/service/config"
microerr "github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
db "github.com/micro/services/db/proto"
"github.com/micro/services/pkg/cache"
user "github.com/micro/services/user/proto"
"github.com/sendgrid/sendgrid-go"
"github.com/sendgrid/sendgrid-go/helpers/mail"
)
var (
ErrNotFound = errors.New("not found")
)
type pw struct {
ID string `json:"id"`
Password string `json:"password"`
Salt string `json:"salt"`
}
type verificationToken struct {
ID string `json:"id"`
UserID string `json:"userId"`
Token string `json:"token"`
}
type passwordResetCode struct {
ID string `json:"id"`
Expires time.Time `json:"expires"`
UserID string `json:"userId"`
Code string `json:"code"`
}
type Domain struct {
db db.DbService
sengridKey string
fromEmail string
}
var (
// TODO: use the config to drive this value
defaultSender = "noreply@email.m3ocontent.com"
)
func New(db db.DbService) *Domain {
var key, email string
cfg, err := config.Get("micro.user.sendgrid.api_key")
if err == nil {
key = cfg.String("")
}
cfg, err = config.Get("micro.user.sendgrid.from_email")
if err == nil {
email = cfg.String(defaultSender)
}
if len(key) == 0 {
logger.Info("No email key found")
} else {
logger.Info("Email key found")
}
return &Domain{
sengridKey: key,
db: db,
fromEmail: email,
}
}
func (domain *Domain) SendEmail(fromName, toAddress, toUsername, subject, textContent, token, redirctUrl, failureRedirectUrl string) error {
if domain.sengridKey == "" {
return fmt.Errorf("empty email api key")
}
from := mail.NewEmail(fromName, domain.fromEmail)
to := mail.NewEmail(toUsername, toAddress)
// set the text content
textContent = strings.Replace(textContent, "$micro_verification_link", "https://user.m3o.com?token="+token+"&redirectUrl="+url.QueryEscape(redirctUrl)+"&failureRedirectUrl="+url.QueryEscape(failureRedirectUrl), -1)
message := mail.NewSingleEmail(from, subject, to, textContent, "")
// send the email
client := sendgrid.NewSendClient(domain.sengridKey)
response, err := client.Send(message)
logger.Info(response)
return err
}
func (domain *Domain) SavePasswordResetCode(ctx context.Context, userID, code string) (*passwordResetCode, error) {
pwcode := passwordResetCode{
ID: userID + "-" + code,
Expires: time.Now().Add(24 * time.Hour),
UserID: userID,
Code: code,
}
s := &_struct.Struct{}
jso, _ := json.Marshal(pwcode)
err := s.UnmarshalJSON(jso)
if err != nil {
return nil, err
}
_, err = domain.db.Create(ctx, &db.CreateRequest{
Table: "password-reset-codes",
Record: s,
})
return &pwcode, err
}
func (domain *Domain) DeletePasswordResetCode(ctx context.Context, userId, code string) error {
_, err := domain.db.Delete(ctx, &db.DeleteRequest{
Table: "password-reset-codes",
Id: userId + "-" + code,
})
return err
}
// ReadToken returns the user id
func (domain *Domain) ReadPasswordResetCode(ctx context.Context, userId, code string) (*passwordResetCode, error) {
// generate the key
id := userId + "-" + code
if id == "" {
return nil, errors.New("password reset code id is empty")
}
token := &passwordResetCode{}
rsp, err := domain.db.Read(ctx, &db.ReadRequest{
Table: "password-reset-codes",
Query: fmt.Sprintf("id == '%v'", id),
})
if err != nil {
return nil, err
}
if len(rsp.Records) == 0 {
return nil, errors.New("password reset code not found")
}
m, _ := rsp.Records[0].MarshalJSON()
json.Unmarshal(m, token)
// check the expiry
if token.Expires.Before(time.Now()) {
return nil, errors.New("password reset code expired")
}
return token, nil
}
func (domain *Domain) SendPasswordResetEmail(ctx context.Context, userId, codeStr, fromName, toAddress, toUsername, subject, textContent string) error {
if domain.sengridKey == "" {
return fmt.Errorf("empty email api key")
}
from := mail.NewEmail(fromName, domain.fromEmail)
to := mail.NewEmail(toUsername, toAddress)
// save the password reset code
pw, err := domain.SavePasswordResetCode(ctx, userId, codeStr)
if err != nil {
return err
}
// set the code in the text content
textContent = strings.Replace(textContent, "$code", pw.Code, -1)
message := mail.NewSingleEmail(from, subject, to, textContent, "")
// send the email
client := sendgrid.NewSendClient(domain.sengridKey)
response, err := client.Send(message)
// log the response
logger.Info(response)
return err
}
func (domain *Domain) CreateSession(ctx context.Context, sess *user.Session) error {
if sess.Created == 0 {
sess.Created = time.Now().Unix()
}
if sess.Expires == 0 {
sess.Expires = time.Now().Add(time.Hour * 24 * 7).Unix()
}
s := &_struct.Struct{}
jso, _ := json.Marshal(sess)
err := s.UnmarshalJSON(jso)
if err != nil {
return err
}
_, err = domain.db.Create(ctx, &db.CreateRequest{
Table: "sessions",
Record: s,
})
return err
}
func (domain *Domain) DeleteSession(ctx context.Context, id string) error {
_, err := domain.db.Delete(ctx, &db.DeleteRequest{
Table: "sessions",
Id: id,
})
return err
}
// ReadToken returns the user id
func (domain *Domain) ReadToken(ctx context.Context, userId, token string) (string, error) {
id := userId + "-" + token
if token == "" {
return "", errors.New("token id empty")
}
tk := &verificationToken{}
rsp, err := domain.db.Read(ctx, &db.ReadRequest{
Table: "tokens",
Query: fmt.Sprintf("id == '%v'", id),
})
if err != nil {
return "", err
}
if len(rsp.Records) == 0 {
return "", errors.New("token not found")
}
m, _ := rsp.Records[0].MarshalJSON()
json.Unmarshal(m, tk)
return tk.UserID, nil
}
// CreateToken returns the created and saved token
func (domain *Domain) CreateToken(ctx context.Context, userId, token string) (string, error) {
s := &_struct.Struct{}
jso, _ := json.Marshal(verificationToken{
ID: userId + "-" + token,
UserID: userId,
Token: token,
})
err := s.UnmarshalJSON(jso)
if err != nil {
return "", err
}
_, err = domain.db.Create(ctx, &db.CreateRequest{
Table: "tokens",
Record: s,
})
return token, err
}
func (domain *Domain) ReadSession(ctx context.Context, id string) (*user.Session, error) {
sess := &user.Session{}
if len(id) == 0 {
return nil, fmt.Errorf("no id provided")
}
q := fmt.Sprintf("id == '%v'", id)
logger.Infof("Running query: %v", q)
rsp, err := domain.db.Read(ctx, &db.ReadRequest{
Table: "sessions",
Query: q,
})
if err != nil {
return nil, err
}
if len(rsp.Records) == 0 {
return nil, ErrNotFound
}
m, _ := rsp.Records[0].MarshalJSON()
json.Unmarshal(m, sess)
return sess, nil
}
func (domain *Domain) Create(ctx context.Context, user *user.Account, salt string, password string) error {
user.Created = time.Now().Unix()
user.Updated = time.Now().Unix()
s := &_struct.Struct{}
jso, _ := json.Marshal(user)
err := s.UnmarshalJSON(jso)
if err != nil {
return err
}
_, err = domain.db.Create(ctx, &db.CreateRequest{
Table: "users",
Record: s,
})
if err != nil {
return err
}
pass := pw{
ID: user.Id,
Password: password,
Salt: salt,
}
s = &_struct.Struct{}
jso, _ = json.Marshal(pass)
err = s.UnmarshalJSON(jso)
if err != nil {
return err
}
_, err = domain.db.Create(ctx, &db.CreateRequest{
Table: "passwords",
Record: s,
})
return err
}
func (domain *Domain) Delete(ctx context.Context, id string) error {
_, err := domain.db.Delete(ctx, &db.DeleteRequest{
Table: "users",
Id: id,
})
return err
}
func (domain *Domain) Update(ctx context.Context, user *user.Account) error {
user.Updated = time.Now().Unix()
s := &_struct.Struct{}
jso, _ := json.Marshal(user)
err := s.UnmarshalJSON(jso)
if err != nil {
return err
}
_, err = domain.db.Update(ctx, &db.UpdateRequest{
Table: "users",
Record: s,
})
return err
}
func (domain *Domain) Read(ctx context.Context, userId string) (*user.Account, error) {
user := &user.Account{}
if len(userId) == 0 {
return nil, fmt.Errorf("no id provided")
}
q := fmt.Sprintf("id == '%v'", userId)
logger.Infof("Running query: %v", q)
rsp, err := domain.db.Read(ctx, &db.ReadRequest{
Table: "users",
Query: q,
})
if err != nil {
return nil, err
}
if len(rsp.Records) == 0 {
return nil, ErrNotFound
}
m, _ := rsp.Records[0].MarshalJSON()
json.Unmarshal(m, user)
return user, nil
}
func (domain *Domain) Search(ctx context.Context, username, email string) ([]*user.Account, error) {
var query string
if len(username) > 0 {
query = fmt.Sprintf("username == '%v'", username)
} else if len(email) > 0 {
query = fmt.Sprintf("email == '%v'", email)
} else {
return nil, errors.New("username and email cannot be blank")
}
usr := &user.Account{}
rsp, err := domain.db.Read(ctx, &db.ReadRequest{
Table: "users",
Query: query,
})
if err != nil {
return nil, err
}
if len(rsp.Records) == 0 {
return nil, ErrNotFound
}
m, _ := rsp.Records[0].MarshalJSON()
json.Unmarshal(m, usr)
return []*user.Account{usr}, nil
}
func (domain *Domain) UpdatePassword(ctx context.Context, id string, salt string, password string) error {
pass := pw{
ID: id,
Password: password,
Salt: salt,
}
s := &_struct.Struct{}
jso, _ := json.Marshal(pass)
err := s.UnmarshalJSON(jso)
if err != nil {
return err
}
_, err = domain.db.Update(ctx, &db.UpdateRequest{
Table: "passwords",
Record: s,
})
return err
}
func (domain *Domain) SaltAndPassword(ctx context.Context, userId string) (string, string, error) {
password := &pw{}
rsp, err := domain.db.Read(ctx, &db.ReadRequest{
Table: "passwords",
Query: fmt.Sprintf("id == '%v'", userId),
})
if err != nil {
return "", "", err
}
if len(rsp.Records) == 0 {
return "", "", ErrNotFound
}
m, _ := rsp.Records[0].MarshalJSON()
json.Unmarshal(m, password)
return password.Salt, password.Password, nil
}
func (domain *Domain) List(ctx context.Context, o, l int32) ([]*user.Account, error) {
var limit int32 = 25
var offset int32 = 0
if l > 0 {
limit = l
}
if o > 0 {
offset = o
}
rsp, err := domain.db.Read(ctx, &db.ReadRequest{
Table: "users",
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, err
}
if len(rsp.Records) == 0 {
return nil, ErrNotFound
}
ret := make([]*user.Account, len(rsp.Records))
for i, v := range rsp.Records {
m, _ := v.MarshalJSON()
var user user.Account
json.Unmarshal(m, &user)
ret[i] = &user
}
return ret, nil
}
func (domain *Domain) CacheToken(ctx context.Context, token, email string, ttl int) error {
expires := time.Now().Add(time.Duration(ttl) * time.Second)
err := cache.Context(ctx).Set(token, email, expires)
return err
}
func (domain *Domain) SendMLE(fromName, toAddress, toUsername, subject, textContent, token, address, endpoint string) error {
if domain.sengridKey == "" {
return fmt.Errorf("empty email api key")
}
from := mail.NewEmail(fromName, "support@m3o.com")
to := mail.NewEmail(toUsername, toAddress)
textContent = strings.Replace(textContent, "$micro_verification_link", fmt.Sprint("https://", path.Join(address, endpoint, token)), -1)
message := mail.NewSingleEmail(from, subject, to, textContent, "")
client := sendgrid.NewSendClient(domain.sengridKey)
response, err := client.Send(message)
logger.Info(response)
return err
}
func (domain *Domain) CacheReadToken(ctx context.Context, token string) (string, error) {
if token == "" {
return "", errors.New("token empty")
}
var email string
expires, err := cache.Context(ctx).Get(token, email)
if err != nil && err == cache.ErrNotFound {
return "", errors.New("token not found")
} else if time.Until(expires).Seconds() < 0 {
return "", errors.New("token expired")
} else if err != nil {
return "", microerr.InternalServerError("CacheReadToken", err.Error())
}
return email, nil
}