2021-03-23 08:43:44 +00:00
|
|
|
package goscrobble
|
2021-03-24 09:28:05 +00:00
|
|
|
|
|
|
|
import (
|
2021-03-25 23:21:28 +00:00
|
|
|
"database/sql"
|
|
|
|
"encoding/json"
|
2021-03-24 09:28:05 +00:00
|
|
|
"errors"
|
|
|
|
"fmt"
|
2021-03-25 23:21:28 +00:00
|
|
|
"log"
|
|
|
|
"net"
|
2021-04-01 12:56:08 +00:00
|
|
|
"os"
|
2021-03-25 23:21:28 +00:00
|
|
|
"strings"
|
2021-03-25 10:09:17 +00:00
|
|
|
"time"
|
2021-03-24 09:28:05 +00:00
|
|
|
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
)
|
|
|
|
|
|
|
|
const bCryptCost = 16
|
|
|
|
|
|
|
|
type User struct {
|
2021-03-25 23:21:28 +00:00
|
|
|
UUID string `json:"uuid"`
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
CreatedIp net.IP `json:"created_ip"`
|
|
|
|
ModifiedAt time.Time `json:"modified_at"`
|
|
|
|
ModifiedIP net.IP `jsos:"modified_ip"`
|
|
|
|
Username string `json:"username"`
|
|
|
|
Password []byte `json:"password"`
|
|
|
|
Email string `json:"email"`
|
|
|
|
Verified bool `json:"verified"`
|
|
|
|
Active bool `json:"active"`
|
|
|
|
Admin bool `json:"admin"`
|
2021-08-13 10:38:03 +00:00
|
|
|
Mod bool `json:"mod"`
|
2021-04-02 09:24:00 +00:00
|
|
|
Timezone string `json:"timezone"`
|
2021-04-03 00:54:07 +00:00
|
|
|
Token string `json:"token"`
|
2021-03-25 10:09:17 +00:00
|
|
|
}
|
|
|
|
|
2021-04-01 10:17:46 +00:00
|
|
|
type UserResponse struct {
|
2021-04-02 09:24:00 +00:00
|
|
|
UUID string `json:"uuid"`
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
CreatedIp net.IP `json:"created_ip"`
|
|
|
|
ModifiedAt time.Time `json:"modified_at"`
|
|
|
|
ModifiedIP net.IP `jsos:"modified_ip"`
|
|
|
|
Username string `json:"username"`
|
|
|
|
Email string `json:"email"`
|
|
|
|
Verified bool `json:"verified"`
|
|
|
|
SpotifyUsername string `json:"spotify_username"`
|
|
|
|
Timezone string `json:"timezone"`
|
2021-04-03 00:54:07 +00:00
|
|
|
Token string `json:"token"`
|
2021-04-09 21:49:32 +00:00
|
|
|
NavidromeURL string `json:"navidrome_server"`
|
2021-03-25 23:21:28 +00:00
|
|
|
}
|
|
|
|
|
2021-03-24 09:28:05 +00:00
|
|
|
// createUser - Called from API
|
2021-04-09 21:49:32 +00:00
|
|
|
func createUser(req *RequestRequest, ip net.IP) error {
|
2021-03-24 09:28:05 +00:00
|
|
|
// Check if user already exists..
|
2021-03-25 10:09:17 +00:00
|
|
|
if len(req.Password) < 8 {
|
2021-03-24 09:28:05 +00:00
|
|
|
return errors.New("Password must be at least 8 characters")
|
|
|
|
}
|
|
|
|
|
2021-03-25 23:21:28 +00:00
|
|
|
// Check Username is set
|
2021-03-25 10:09:17 +00:00
|
|
|
if req.Username == "" {
|
2021-03-24 09:28:05 +00:00
|
|
|
return errors.New("A username is required")
|
|
|
|
}
|
|
|
|
|
2021-03-30 08:36:28 +00:00
|
|
|
// Check username is valid
|
|
|
|
if !isUsernameValid(req.Username) {
|
2021-03-25 23:21:28 +00:00
|
|
|
return errors.New("Username contains invalid characters")
|
|
|
|
}
|
|
|
|
|
2021-03-25 10:09:17 +00:00
|
|
|
// If set an email.. validate it!
|
|
|
|
if req.Email != "" {
|
|
|
|
if !isEmailValid(req.Email) {
|
|
|
|
return errors.New("Invalid email address")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-03-24 09:28:05 +00:00
|
|
|
// Check if user or email exists!
|
2021-03-25 10:09:17 +00:00
|
|
|
if userAlreadyExists(req) {
|
2021-03-24 09:28:05 +00:00
|
|
|
return errors.New("Username or email already exists")
|
|
|
|
}
|
|
|
|
|
|
|
|
// Lets hashit!
|
2021-03-25 10:09:17 +00:00
|
|
|
hash, err := hashPassword(req.Password)
|
2021-03-24 09:28:05 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2021-03-25 23:21:28 +00:00
|
|
|
return insertUser(req.Username, req.Email, hash, ip)
|
|
|
|
}
|
|
|
|
|
2021-04-09 21:49:32 +00:00
|
|
|
func loginUser(logReq *RequestRequest, ip net.IP) ([]byte, error) {
|
2021-03-25 23:21:28 +00:00
|
|
|
var resp []byte
|
|
|
|
var user User
|
|
|
|
|
|
|
|
if logReq.Username == "" {
|
2021-03-27 04:05:05 +00:00
|
|
|
return resp, errors.New("A username is required")
|
2021-03-25 23:21:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if logReq.Password == "" {
|
2021-03-27 04:05:05 +00:00
|
|
|
return resp, errors.New("A password is required")
|
2021-03-25 23:21:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if strings.Contains(logReq.Username, "@") {
|
2021-12-25 09:24:47 +00:00
|
|
|
err := db.QueryRow("SELECT uuid, username, email, password, admin, mod FROM users WHERE email = $1 AND active = true",
|
2021-08-13 10:38:03 +00:00
|
|
|
logReq.Username).Scan(&user.UUID, &user.Username, &user.Email, &user.Password, &user.Admin, &user.Mod)
|
2021-03-25 23:21:28 +00:00
|
|
|
if err != nil {
|
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return resp, errors.New("Invalid Username or Password")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
2021-12-25 09:24:47 +00:00
|
|
|
err := db.QueryRow("SELECT uuid, username, email, password, admin, mod FROM users WHERE username = $1 AND active = true",
|
2021-08-13 10:38:03 +00:00
|
|
|
logReq.Username).Scan(&user.UUID, &user.Username, &user.Email, &user.Password, &user.Admin, &user.Mod)
|
2021-03-25 23:21:28 +00:00
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return resp, errors.New("Invalid Username or Password")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if !isValidPassword(logReq.Password, user) {
|
|
|
|
return resp, errors.New("Invalid Username or Password")
|
|
|
|
}
|
|
|
|
|
|
|
|
// Issue JWT + Response
|
2021-04-06 08:28:04 +00:00
|
|
|
token, err := generateJWTToken(user, "")
|
2021-03-25 23:21:28 +00:00
|
|
|
if err != nil {
|
|
|
|
log.Printf("Error generating JWT: %v", err)
|
|
|
|
return resp, errors.New("Error logging in")
|
|
|
|
}
|
|
|
|
|
2021-04-09 21:49:32 +00:00
|
|
|
loginResp := RequestResponse{
|
2021-03-25 23:21:28 +00:00
|
|
|
Token: token,
|
|
|
|
}
|
|
|
|
|
|
|
|
resp, _ = json.Marshal(&loginResp)
|
|
|
|
return resp, nil
|
|
|
|
}
|
|
|
|
|
2021-03-24 09:28:05 +00:00
|
|
|
// insertUser - Does the dirtywork!
|
2021-03-25 23:21:28 +00:00
|
|
|
func insertUser(username string, email string, password []byte, ip net.IP) error {
|
2021-03-28 08:52:34 +00:00
|
|
|
token := generateToken(32)
|
2021-12-25 09:24:47 +00:00
|
|
|
uuid := newUUID()
|
|
|
|
|
|
|
|
log.Printf(ip.String())
|
|
|
|
|
2021-03-28 08:52:34 +00:00
|
|
|
_, err := db.Exec("INSERT INTO users (uuid, created_at, created_ip, modified_at, modified_ip, username, email, password, token) "+
|
2021-12-25 09:24:47 +00:00
|
|
|
"VALUES ($1,NOW(),$2,NOW(),$3,$4,$5,$6,$7)", uuid, ip.String(), ip.String(), username, email, password, token)
|
2021-03-25 23:21:28 +00:00
|
|
|
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2021-04-02 12:11:05 +00:00
|
|
|
func (user *User) updateUser(field string, value string, ip net.IP) error {
|
2021-12-25 09:24:47 +00:00
|
|
|
_, err := db.Exec("UPDATE users SET "+field+" = $1, modified_at = NOW(), modified_ip = $2 WHERE uuid = $3", value, ip, user.UUID)
|
2021-03-25 23:21:28 +00:00
|
|
|
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2021-04-02 12:11:05 +00:00
|
|
|
func (user *User) updateUserDirect(field string, value string) error {
|
2021-12-25 09:24:47 +00:00
|
|
|
_, err := db.Exec("UPDATE users SET "+field+" = $1 WHERE uuid = $2", value, user.UUID)
|
2021-03-24 09:28:05 +00:00
|
|
|
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// hashPassword - Returns bcrypt hash
|
|
|
|
func hashPassword(password string) ([]byte, error) {
|
|
|
|
return bcrypt.GenerateFromPassword([]byte(password), bCryptCost)
|
|
|
|
}
|
|
|
|
|
|
|
|
// isValidPassword - Checks if password is valid
|
|
|
|
func isValidPassword(password string, user User) bool {
|
2021-03-25 23:21:28 +00:00
|
|
|
err := bcrypt.CompareHashAndPassword(user.Password, []byte(password))
|
2021-03-24 09:28:05 +00:00
|
|
|
if err != nil {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
// userAlreadyExists - Returns bool indicating if a record exists for either username or email
|
|
|
|
// Using two look ups to make use of DB indexes.
|
2021-04-09 21:49:32 +00:00
|
|
|
func userAlreadyExists(req *RequestRequest) bool {
|
2021-12-25 09:24:47 +00:00
|
|
|
count, err := getDbCount("SELECT COUNT(*) FROM users WHERE username = $1", req.Username)
|
2021-03-25 23:21:28 +00:00
|
|
|
if err != nil {
|
|
|
|
fmt.Printf("Error querying for duplicate users: %v", err)
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
if count > 0 {
|
2021-03-25 10:09:17 +00:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
if req.Email != "" {
|
|
|
|
// Only run email check if there's an email...
|
2021-12-25 09:24:47 +00:00
|
|
|
count, err = getDbCount("SELECT COUNT(*) FROM users WHERE email = $1", req.Email)
|
2021-03-24 09:28:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
fmt.Printf("Error querying for duplicate users: %v", err)
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2021-03-25 23:21:28 +00:00
|
|
|
return count > 0
|
2021-03-24 09:28:05 +00:00
|
|
|
}
|
2021-03-31 08:40:20 +00:00
|
|
|
|
2021-04-04 09:54:53 +00:00
|
|
|
func getUserByUUID(uuid string) (User, error) {
|
2021-03-31 08:40:20 +00:00
|
|
|
var user User
|
2021-12-25 09:24:47 +00:00
|
|
|
err := db.QueryRow("SELECT uuid, created_at, created_ip, modified_at, modified_ip, username, email, password, verified, admin, mod, timezone, token FROM users WHERE uuid = $1 AND active = true",
|
2021-08-13 10:38:03 +00:00
|
|
|
uuid).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin, &user.Mod, &user.Timezone, &user.Token)
|
2021-03-31 08:40:20 +00:00
|
|
|
|
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return user, errors.New("Invalid JWT Token")
|
|
|
|
}
|
|
|
|
|
|
|
|
return user, nil
|
|
|
|
}
|
2021-04-01 10:17:46 +00:00
|
|
|
|
|
|
|
func getUserByUsername(username string) (User, error) {
|
|
|
|
var user User
|
2021-12-25 09:24:47 +00:00
|
|
|
err := db.QueryRow("SELECT uuid, created_at, created_ip, modified_at, modified_ip, username, email, password, verified, admin, mod, timezone, token FROM users WHERE username = $1 AND active = true",
|
2021-08-13 10:38:03 +00:00
|
|
|
username).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin, &user.Mod, &user.Timezone, &user.Token)
|
2021-04-01 10:17:46 +00:00
|
|
|
|
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return user, errors.New("Invalid Username")
|
|
|
|
}
|
|
|
|
|
|
|
|
return user, nil
|
|
|
|
}
|
2021-04-01 12:56:08 +00:00
|
|
|
|
|
|
|
func getUserByEmail(email string) (User, error) {
|
|
|
|
var user User
|
2021-12-25 09:24:47 +00:00
|
|
|
err := db.QueryRow("SELECT uuid, created_at, created_ip, modified_at, modified_ip, username, email, password, verified, admin, mod, timezone, token FROM users WHERE email = $1 AND active = true",
|
2021-08-13 10:38:03 +00:00
|
|
|
email).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin, &user.Mod, &user.Timezone, &user.Token)
|
2021-04-01 12:56:08 +00:00
|
|
|
|
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return user, errors.New("Invalid Email")
|
|
|
|
}
|
|
|
|
|
|
|
|
return user, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func getUserByResetToken(token string) (User, error) {
|
|
|
|
var user User
|
2021-12-25 09:24:47 +00:00
|
|
|
err := db.QueryRow("SELECT users.uuid, created_at, created_ip, modified_at, modified_ip, username, email, password, verified, admin, mod, timezone, token FROM users "+
|
|
|
|
"JOIN resettoken ON resettoken.user = users.uuid WHERE resettoken.token = $1 AND active = true",
|
2021-08-13 10:38:03 +00:00
|
|
|
token).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin, &user.Mod, &user.Timezone, &user.Token)
|
2021-04-01 12:56:08 +00:00
|
|
|
|
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return user, errors.New("Invalid Token")
|
|
|
|
}
|
|
|
|
|
|
|
|
return user, nil
|
|
|
|
}
|
2021-04-02 09:24:00 +00:00
|
|
|
|
2021-04-01 12:56:08 +00:00
|
|
|
func (user *User) sendResetEmail(ip net.IP) error {
|
|
|
|
token := generateToken(16)
|
|
|
|
|
2021-04-02 09:24:00 +00:00
|
|
|
// 1 hour validation
|
|
|
|
exp := time.Now().Add(time.Hour * time.Duration(1))
|
2021-04-01 12:56:08 +00:00
|
|
|
err := user.saveResetToken(token, exp)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
content := fmt.Sprintf(
|
2021-04-02 09:24:00 +00:00
|
|
|
"Someone at %s has request a password reset for %s.\n"+
|
|
|
|
"Click the following link to reset your password: %s/reset/%s\n\n"+
|
|
|
|
"This is link is valid for 1 hour",
|
2021-04-01 12:56:08 +00:00
|
|
|
ip, user.Username, os.Getenv("GOSCROBBLE_DOMAIN"), token)
|
|
|
|
|
|
|
|
return sendEmail(user.Username, user.Email, "GoScrobble - Password Reset", content)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (user *User) saveResetToken(token string, expiry time.Time) error {
|
2021-12-25 09:24:47 +00:00
|
|
|
_, _ = db.Exec("DELETE FROM resettoken WHERE user = $1", user.UUID)
|
|
|
|
_, err := db.Exec("INSERT INTO resettoken (user, token, expiry) "+
|
|
|
|
"VALUES ($1,$2,$3)", user.UUID, token, expiry)
|
2021-04-01 12:56:08 +00:00
|
|
|
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
func clearOldResetTokens() {
|
2021-12-25 09:24:47 +00:00
|
|
|
_, _ = db.Exec("DELETE FROM resettoken WHERE expiry < NOW()")
|
2021-04-01 12:56:08 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func clearResetToken(token string) error {
|
2021-12-25 09:24:47 +00:00
|
|
|
_, err := db.Exec("DELETE FROM resettoken WHERE token = $1", token)
|
2021-04-01 12:56:08 +00:00
|
|
|
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// checkResetToken - If a token exists check it
|
|
|
|
func checkResetToken(token string) (bool, error) {
|
2021-12-25 09:24:47 +00:00
|
|
|
count, err := getDbCount("SELECT COUNT(*) FROM resettoken WHERE token = $1", token)
|
2021-04-01 12:56:08 +00:00
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return count > 0, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (user *User) updatePassword(newPassword string, ip net.IP) error {
|
|
|
|
hash, err := hashPassword(newPassword)
|
|
|
|
if err != nil {
|
|
|
|
return errors.New("Bad password")
|
|
|
|
}
|
|
|
|
|
2021-12-25 09:24:47 +00:00
|
|
|
_, err = db.Exec("UPDATE users SET password = $1 WHERE uuid = $2", hash, user.UUID)
|
2021-04-01 12:56:08 +00:00
|
|
|
if err != nil {
|
|
|
|
return errors.New("Failed to update password")
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
2021-04-02 09:24:00 +00:00
|
|
|
|
|
|
|
func (user *User) getSpotifyTokens() (OauthToken, error) {
|
|
|
|
return getOauthToken(user.UUID, "spotify")
|
|
|
|
}
|
|
|
|
|
2021-04-09 21:49:32 +00:00
|
|
|
func (user *User) getNavidromeTokens() (OauthToken, error) {
|
|
|
|
return getOauthToken(user.UUID, "navidrome")
|
|
|
|
}
|
|
|
|
|
2021-04-02 09:24:00 +00:00
|
|
|
func getAllSpotifyUsers() ([]User, error) {
|
|
|
|
users := make([]User, 0)
|
2021-12-25 09:24:47 +00:00
|
|
|
rows, err := db.Query("SELECT users.uuid, created_at, created_ip, modified_at, modified_ip, users.username, email, password, verified, admin, mod, timezone FROM users " +
|
|
|
|
"JOIN oauth_tokens ON oauth_tokens.user = users.uuid AND oauth_tokens.service = 'spotify' WHERE users.active = true")
|
2021-04-02 09:24:00 +00:00
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("Failed to fetch spotify users: %+v", err)
|
|
|
|
return users, errors.New("Failed to fetch configs")
|
|
|
|
}
|
|
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
var user User
|
2021-08-13 10:38:03 +00:00
|
|
|
err := rows.Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin, &user.Mod, &user.Timezone)
|
2021-04-02 09:24:00 +00:00
|
|
|
if err != nil {
|
|
|
|
log.Printf("Failed to fetch spotify user: %+v", err)
|
|
|
|
return users, errors.New("Failed to fetch users")
|
|
|
|
}
|
|
|
|
|
|
|
|
users = append(users, user)
|
|
|
|
}
|
|
|
|
|
|
|
|
err = rows.Err()
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("Failed to fetch spotify users: %+v", err)
|
|
|
|
return users, errors.New("Failed to fetch users")
|
|
|
|
}
|
|
|
|
|
|
|
|
return users, nil
|
|
|
|
}
|
2021-04-09 21:49:32 +00:00
|
|
|
|
|
|
|
func getAllNavidromeUsers() ([]User, error) {
|
|
|
|
users := make([]User, 0)
|
2021-12-25 09:24:47 +00:00
|
|
|
rows, err := db.Query("SELECT users.uuid, created_at, created_ip, modified_at, modified_ip, users.username, email, password, verified, admin, mod, timezone FROM users " +
|
|
|
|
"JOIN oauth_tokens ON oauth_tokens.user = users.uuid AND oauth_tokens.service = 'navidrome' WHERE users.active = true")
|
2021-04-09 21:49:32 +00:00
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("Failed to fetch navidrome users: %+v", err)
|
|
|
|
return users, errors.New("Failed to fetch configs")
|
|
|
|
}
|
|
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
var user User
|
2021-08-13 10:38:03 +00:00
|
|
|
err := rows.Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin, &user.Mod, &user.Timezone)
|
2021-04-09 21:49:32 +00:00
|
|
|
if err != nil {
|
|
|
|
log.Printf("Failed to fetch navidrome user: %+v", err)
|
|
|
|
return users, errors.New("Failed to fetch users")
|
|
|
|
}
|
|
|
|
|
|
|
|
users = append(users, user)
|
|
|
|
}
|
|
|
|
|
|
|
|
err = rows.Err()
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("Failed to fetch navidrome users: %+v", err)
|
|
|
|
return users, errors.New("Failed to fetch users")
|
|
|
|
}
|
|
|
|
|
|
|
|
return users, nil
|
|
|
|
}
|