Skip to content

Commit c762b6f

Browse files
authored
Merge pull request #1 from isoppp/feature/add-session-management
add session management
2 parents 71c720e + 7a8d52b commit c762b6f

18 files changed

+2488
-6
lines changed

cmd/api/main.go

+5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"sandbox-go-api-sqlboiler-rest-auth/internal/config"
88
"sandbox-go-api-sqlboiler-rest-auth/internal/routes"
99

10+
"github.com/volatiletech/sqlboiler/v4/boil"
11+
1012
"go.uber.org/zap/zapcore"
1113

1214
"go.uber.org/zap"
@@ -42,6 +44,9 @@ func main() {
4244
_ = db.Close()
4345
}()
4446

47+
// sqlboiler debug mode
48+
boil.DebugMode = true
49+
4550
e := routes.NewRouter(db, logger)
4651
e.Logger.Fatal(e.Start(fmt.Sprintf(":%s", cfg.Port)))
4752
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE IF EXISTS sessions;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
CREATE TABLE "sessions"
2+
(
3+
"id" uuid PRIMARY KEY NOT NULL,
4+
"user_id" int NOT NULL,
5+
"expires_at" timestamp NOT NULL,
6+
"created_at" timestamptz NOT NULL DEFAULT (now())
7+
);
8+
9+
ALTER TABLE "sessions"
10+
ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id");
11+
CREATE INDEX ON "sessions" ("expires_at");

go.mod

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ go 1.17
55
require (
66
github.com/caarlos0/env/v6 v6.7.1
77
github.com/friendsofgo/errors v0.9.2
8+
github.com/google/uuid v1.3.0
9+
github.com/gorilla/securecookie v1.1.1
810
github.com/kat-co/vala v0.0.0-20170210184112-42e1d8b61f12
911
github.com/labstack/echo/v4 v4.5.0
1012
github.com/lib/pq v1.10.3
@@ -20,12 +22,15 @@ require (
2022
github.com/gofrs/uuid v3.2.0+incompatible // indirect
2123
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
2224
github.com/hashicorp/hcl v1.0.0 // indirect
25+
github.com/kr/text v0.2.0 // indirect
2326
github.com/labstack/gommon v0.3.0 // indirect
2427
github.com/magiconair/properties v1.8.5 // indirect
2528
github.com/mattn/go-colorable v0.1.8 // indirect
2629
github.com/mattn/go-isatty v0.0.12 // indirect
2730
github.com/mitchellh/mapstructure v1.4.1 // indirect
31+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
2832
github.com/pelletier/go-toml v1.9.3 // indirect
33+
github.com/pkg/errors v0.9.1 // indirect
2934
github.com/spf13/afero v1.6.0 // indirect
3035
github.com/spf13/cast v1.3.1 // indirect
3136
github.com/spf13/jwalterweatherman v1.1.0 // indirect
@@ -42,6 +47,7 @@ require (
4247
golang.org/x/text v0.3.6 // indirect
4348
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect
4449
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
50+
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
4551
gopkg.in/ini.v1 v1.62.0 // indirect
4652
gopkg.in/yaml.v2 v2.4.0 // indirect
4753
)

go.sum

+13-4
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7
7777
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
7878
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
7979
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
80+
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
8081
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8182
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
8283
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -175,10 +176,14 @@ github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLe
175176
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
176177
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
177178
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
179+
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
180+
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
178181
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
179182
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
180183
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
181184
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
185+
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
186+
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
182187
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
183188
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
184189
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
@@ -224,11 +229,11 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
224229
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
225230
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
226231
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
227-
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
228232
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
229233
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
230-
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
231234
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
235+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
236+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
232237
github.com/labstack/echo/v4 v4.5.0 h1:JXk6H5PAw9I3GwizqUHhYyS4f45iyGebR/c1xNCeOCY=
233238
github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y=
234239
github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
@@ -268,14 +273,17 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
268273
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
269274
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
270275
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
276+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
277+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
271278
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
272279
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
273280
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
274281
github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ=
275282
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
276283
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
277-
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
278284
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
285+
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
286+
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
279287
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
280288
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
281289
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -716,8 +724,9 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
716724
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
717725
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
718726
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
719-
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
720727
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
728+
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
729+
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
721730
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
722731
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
723732
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=

internal/handlers/session.go

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package handlers
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"sandbox-go-api-sqlboiler-rest-auth/internal/scookie"
7+
"sandbox-go-api-sqlboiler-rest-auth/models"
8+
"time"
9+
10+
"github.com/google/uuid"
11+
"github.com/volatiletech/sqlboiler/v4/boil"
12+
13+
"github.com/volatiletech/sqlboiler/v4/queries/qm"
14+
15+
"github.com/labstack/echo/v4"
16+
)
17+
18+
type CreateSessionRequest struct {
19+
Email string `json:"email"`
20+
Password string `json:"password"`
21+
}
22+
23+
func (h *Handlers) CreateSession(c echo.Context) error {
24+
ctx := context.Background()
25+
req := new(CreateUserRequest)
26+
if err := c.Bind(req); err != nil {
27+
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
28+
}
29+
30+
ok, _ := models.Users(qm.Where("email = ?", req.Email)).Exists(ctx, h.db)
31+
if !ok {
32+
return echo.NewHTTPError(http.StatusNotFound, http.StatusText(http.StatusNotFound))
33+
}
34+
u, err := models.Users(qm.Where("email = ?", req.Email)).One(ctx, h.db)
35+
if err != nil {
36+
return echo.NewHTTPError(http.StatusInternalServerError)
37+
}
38+
h.slogger.Info(u)
39+
if u.HashedPassword != req.Password {
40+
return echo.NewHTTPError(http.StatusBadRequest)
41+
}
42+
43+
var s models.Session
44+
var uid, _ = uuid.NewUUID()
45+
s.ID = uid.String()
46+
s.UserID = u.ID
47+
s.ExpiresAt = time.Now().Add(time.Hour * 24 * 30)
48+
err = s.Insert(ctx, h.db, boil.Infer())
49+
if err != nil {
50+
return echo.NewHTTPError(http.StatusInternalServerError)
51+
}
52+
53+
sc := scookie.NewSecureCookie()
54+
encoded, err := sc.Encode("session", s.ID)
55+
if err != nil {
56+
return echo.NewHTTPError(http.StatusInternalServerError)
57+
}
58+
cookie := &http.Cookie{
59+
Name: "session",
60+
Value: encoded,
61+
Path: "/",
62+
Expires: time.Now().Add(time.Hour * 24 * 30),
63+
Secure: true,
64+
HttpOnly: true,
65+
SameSite: 2,
66+
}
67+
c.SetCookie(cookie)
68+
return c.NoContent(http.StatusNoContent)
69+
}
70+
71+
func (h *Handlers) DeleteSession(c echo.Context) error {
72+
return c.NoContent(http.StatusNoContent)
73+
}

internal/handlers/status.go

+15
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
11
package handlers
22

33
import (
4+
"fmt"
45
"net/http"
6+
"sandbox-go-api-sqlboiler-rest-auth/models"
57

68
"github.com/labstack/echo/v4"
79
)
810

11+
type CookieValue struct {
12+
UserID int
13+
Name string
14+
}
15+
916
func (h *Handlers) GetStatus(c echo.Context) error {
17+
var u *models.User
18+
uv := c.Get("user")
19+
if uv != nil {
20+
u = uv.(*models.User)
21+
fmt.Println("user data?", u)
22+
} else {
23+
fmt.Println("not set user session")
24+
}
1025
return c.String(http.StatusOK, "server is running")
1126
}

internal/routes/middlewares.go

+37
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package routes
22

33
import (
4+
"database/sql"
45
"fmt"
6+
"sandbox-go-api-sqlboiler-rest-auth/internal/scookie"
7+
"sandbox-go-api-sqlboiler-rest-auth/models"
58
"time"
69

710
"go.uber.org/zap"
@@ -10,6 +13,40 @@ import (
1013
"github.com/labstack/echo/v4"
1114
)
1215

16+
func SessionRestorer(db *sql.DB) echo.MiddlewareFunc {
17+
return func(next echo.HandlerFunc) echo.HandlerFunc {
18+
return func(c echo.Context) error {
19+
fmt.Println("test middleware")
20+
sc := scookie.NewSecureCookie()
21+
22+
cv, err := c.Cookie("session")
23+
if err != nil {
24+
return next(c)
25+
}
26+
27+
var dv string
28+
err = sc.Decode("session", cv.Value, &dv)
29+
if err != nil {
30+
return echo.NewHTTPError(500, "cannot decode cookie", err)
31+
}
32+
fmt.Println("got cookie(session id): ", dv)
33+
34+
sess, err := models.FindSession(c.Request().Context(), db, dv)
35+
if err != nil {
36+
// maybe wrong cookie id?
37+
return echo.NewHTTPError(500, "cannot get cookie, but got session id", dv, err)
38+
}
39+
user, err := sess.User().One(c.Request().Context(), db)
40+
if err != nil {
41+
return echo.NewHTTPError(500, "cannod find user from session relation", dv, err)
42+
}
43+
fmt.Println("got user in middleware", user)
44+
c.Set("user", user)
45+
return next(c)
46+
}
47+
}
48+
}
49+
1350
func ZapLogger(log *zap.Logger) echo.MiddlewareFunc {
1451
return func(next echo.HandlerFunc) echo.HandlerFunc {
1552
return func(c echo.Context) error {

internal/routes/routes.go

+8-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717
func NewRouter(db *sql.DB, l *zap.Logger) *echo.Echo {
1818
h := handlers.NewHandler(db, l)
1919
e := echo.New()
20-
bindRouteMiddlewares(e, l)
20+
bindRouteMiddlewares(e, l, db)
2121

2222
// routes
2323
e.GET("/api/status", h.GetStatus)
@@ -28,20 +28,26 @@ func NewRouter(db *sql.DB, l *zap.Logger) *echo.Echo {
2828
}
2929

3030
func bindRoutes(e *echo.Echo, h *handlers.Handlers) {
31+
// session
32+
e.POST("/api/v1/sessions", h.CreateSession)
33+
e.DELETE("/api/v1/sessions", h.DeleteSession)
34+
35+
// users
3136
e.GET("/api/v1/users", h.GetUsers)
3237
e.POST("/api/v1/users", h.CreateUser)
3338
e.GET("/api/v1/users/:id", h.GetUser)
3439
e.PATCH("/api/v1/users/:id", h.PatchUser)
3540
e.DELETE("/api/v1/users/:id", h.DeleteUser)
3641
}
3742

38-
func bindRouteMiddlewares(e *echo.Echo, logger *zap.Logger) {
43+
func bindRouteMiddlewares(e *echo.Echo, logger *zap.Logger, db *sql.DB) {
3944
// middlewares
4045
e.Pre(middleware.RemoveTrailingSlash())
4146
e.Use(ZapLogger(logger))
4247
e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(20)))
4348
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{}))
4449
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{}))
50+
e.Use(SessionRestorer(db))
4551

4652
// middlewares if production
4753
//e.Use(middleware.CORSWithConfig(middleware.CORSConfig{

internal/scookie/scookie.go

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package scookie
2+
3+
import (
4+
"github.com/gorilla/securecookie"
5+
)
6+
7+
func NewSecureCookie() *securecookie.SecureCookie {
8+
var hashKey = []byte("jkb2kJU4C6ad11DOFElCYMhtF8kvw75n")
9+
return securecookie.New(hashKey, nil)
10+
}

internal/util/random.go

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package util
2+
3+
import (
4+
"fmt"
5+
"math/rand"
6+
"strings"
7+
"time"
8+
)
9+
10+
const alphabet = "abcdefghijklmnopqrstuvwxyz"
11+
12+
func init() {
13+
rand.Seed(time.Now().UnixNano())
14+
}
15+
16+
// RandomInt generates a random integer between min and max
17+
func RandomInt(min, max int) int {
18+
return min + rand.Intn(max-min+1)
19+
}
20+
21+
// RandomString generates a random string of length n
22+
func RandomString(n int) string {
23+
var sb strings.Builder
24+
k := len(alphabet)
25+
26+
for i := 0; i < n; i++ {
27+
c := alphabet[rand.Intn(k)]
28+
sb.WriteByte(c)
29+
}
30+
31+
return sb.String()
32+
}
33+
34+
// RandomEmail generates a random email
35+
func RandomEmail() string {
36+
return fmt.Sprintf("%[email protected]", RandomString(6))
37+
}

0 commit comments

Comments
 (0)