1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

feat: user signup

This commit is contained in:
Ferdinand Mütsch 2020-05-24 16:34:32 +02:00
parent a317dc6942
commit abfaa9d768
10 changed files with 280 additions and 58 deletions

84
main.go
View File

@ -1,8 +1,6 @@
package main
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"github.com/gorilla/handlers"
"github.com/gorilla/securecookie"
@ -19,7 +17,6 @@ import (
"github.com/jinzhu/gorm"
"github.com/joho/godotenv"
"github.com/rubenv/sql-migrate"
uuid "github.com/satori/go.uuid"
ini "gopkg.in/ini.v1"
"github.com/muety/wakapi/middlewares"
@ -33,6 +30,19 @@ import (
_ "github.com/jinzhu/gorm/dialects/sqlite"
)
var (
db *gorm.DB
config *models.Config
)
var (
aliasService *services.AliasService
heartbeatService *services.HeartbeatService
userService *services.UserService
summaryService *services.SummaryService
aggregationService *services.AggregationService
)
// TODO: Refactor entire project to be structured after business domains
func readConfig() *models.Config {
@ -125,14 +135,16 @@ func readConfig() *models.Config {
func main() {
// Read Config
config := readConfig()
config = readConfig()
// Enable line numbers in logging
if config.IsDev() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
// Connect to database
db, err := gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
var err error
db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
if config.DbDialect == "sqlite3" {
db.DB().Exec("PRAGMA foreign_keys = ON;")
}
@ -150,34 +162,34 @@ func main() {
migrateDo := databaseMigrateActions(config.DbDialect)
migrateDo(db)
// Custom migrations and initial data
addDefaultUser(db, config)
migrateLanguages(db, config)
// Services
aliasSrvc := &services.AliasService{Config: config, Db: db}
heartbeatSrvc := &services.HeartbeatService{Config: config, Db: db}
userSrvc := &services.UserService{Config: config, Db: db}
summarySrvc := &services.SummaryService{Config: config, Db: db, HeartbeatService: heartbeatSrvc, AliasService: aliasSrvc}
aggregationSrvc := &services.AggregationService{Config: config, Db: db, UserService: userSrvc, SummaryService: summarySrvc, HeartbeatService: heartbeatSrvc}
aliasService = &services.AliasService{Config: config, Db: db}
heartbeatService = &services.HeartbeatService{Config: config, Db: db}
userService = &services.UserService{Config: config, Db: db}
summaryService = &services.SummaryService{Config: config, Db: db, HeartbeatService: heartbeatService, AliasService: aliasService}
aggregationService = &services.AggregationService{Config: config, Db: db, UserService: userService, SummaryService: summaryService, HeartbeatService: heartbeatService}
svcs := []services.Initializable{aliasSrvc, heartbeatSrvc, summarySrvc, userSrvc, aggregationSrvc}
svcs := []services.Initializable{aliasService, heartbeatService, userService, summaryService, aggregationService}
for _, s := range svcs {
s.Init()
}
// Custom migrations and initial data
addDefaultUser()
migrateLanguages()
// Aggregate heartbeats to summaries and persist them
go aggregationSrvc.Schedule()
go aggregationService.Schedule()
if config.CleanUp {
go heartbeatSrvc.ScheduleCleanUp()
go heartbeatService.ScheduleCleanUp()
}
// Handlers
heartbeatHandler := routes.NewHeartbeatHandler(config, heartbeatSrvc)
summaryHandler := routes.NewSummaryHandler(config, summarySrvc)
heartbeatHandler := routes.NewHeartbeatHandler(config, heartbeatService)
summaryHandler := routes.NewSummaryHandler(config, summaryService)
healthHandler := routes.NewHealthHandler(db)
indexHandler := routes.NewIndexHandler(config, userSrvc)
indexHandler := routes.NewIndexHandler(config, userService)
// Setup Routers
router := mux.NewRouter()
@ -191,7 +203,7 @@ func main() {
corsMiddleware := handlers.CORS()
authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
config,
userSrvc,
userService,
[]string{"/api/health"},
).Handler
@ -204,6 +216,7 @@ func main() {
indexRouter.Path("/").Methods(http.MethodGet).HandlerFunc(indexHandler.Index)
indexRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(indexHandler.Login)
indexRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(indexHandler.Logout)
indexRouter.Path("/signup").Methods(http.MethodGet, http.MethodPost).HandlerFunc(indexHandler.Signup)
// Summary Routes
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.Index)
@ -254,8 +267,8 @@ func databaseMigrateActions(dbDialect string) func(db *gorm.DB) {
return migrateDo
}
func migrateLanguages(db *gorm.DB, cfg *models.Config) {
for k, v := range cfg.CustomLanguages {
func migrateLanguages() {
for k, v := range config.CustomLanguages {
result := db.Model(models.Heartbeat{}).
Where("language = ?", "").
Where("entity LIKE ?", "%."+k).
@ -269,17 +282,18 @@ func migrateLanguages(db *gorm.DB, cfg *models.Config) {
}
}
func addDefaultUser(db *gorm.DB, cfg *models.Config) {
pw := md5.Sum([]byte(cfg.DefaultUserPassword))
pwString := hex.EncodeToString(pw[:])
apiKey := uuid.NewV4().String()
u := &models.User{ID: cfg.DefaultUserName, Password: pwString, ApiKey: apiKey}
result := db.FirstOrCreate(u, &models.User{ID: u.ID})
if result.Error != nil {
log.Println("Unable to create default user.")
log.Fatal(result.Error)
}
if result.RowsAffected > 0 {
log.Printf("Created default user '%s' with password '%s' and API key '%s'.\n", u.ID, cfg.DefaultUserPassword, u.ApiKey)
func addDefaultUser() {
u, created, err := userService.CreateOrGet(&models.Signup{
Username: config.DefaultUserName,
Password: config.DefaultUserPassword,
})
if err != nil {
log.Println("unable to create default user")
log.Fatal(err)
} else if created {
log.Printf("created default user '%s' with password '%s' and API key '%s'\n", u.ID, config.DefaultUserPassword, u.ApiKey)
} else {
log.Printf("default user '%s' already existing\n", u.ID)
}
}

View File

@ -40,4 +40,5 @@ type SummaryViewModel struct {
*Summary
LanguageColors map[string]string
Error string
Success string
}

View File

@ -7,6 +7,12 @@ type User struct {
}
type Login struct {
Username string `json:"username"`
Password string `json:"password"`
Username string `schema:"username"`
Password string `schema:"password"`
}
type Signup struct {
Username string `schema:"username"`
Password string `schema:"password"`
PasswordRepeat string `schema:"password_repeat"`
}

View File

@ -1,11 +1,13 @@
package routes
import (
"fmt"
"github.com/gorilla/schema"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"net/url"
)
type IndexHandler struct {
@ -14,6 +16,7 @@ type IndexHandler struct {
}
var loginDecoder = schema.NewDecoder()
var signupDecoder = schema.NewDecoder()
func NewIndexHandler(config *models.Config, userService *services.UserService) *IndexHandler {
return &IndexHandler{
@ -32,15 +35,18 @@ func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) {
return
}
if err := r.URL.Query().Get("error"); err != "" {
if err == "unauthorized" {
respondError(w, err, http.StatusUnauthorized)
} else {
respondError(w, err, http.StatusInternalServerError)
}
if handleAlerts(w, r, "") {
return
}
// TODO: make this more generic and reusable
if success := r.URL.Query().Get("success"); success != "" {
templates["index.tpl.html"].Execute(w, struct {
Success string
Error string
}{Success: success})
return
}
templates["index.tpl.html"].Execute(w, nil)
}
@ -49,30 +55,35 @@ func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, "/summary", http.StatusFound)
return
}
var login models.Login
if err := r.ParseForm(); err != nil {
respondError(w, "missing parameters", http.StatusBadRequest)
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
return
}
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
respondError(w, "missing parameters", http.StatusBadRequest)
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
return
}
user, err := h.userSrvc.GetUserById(login.Username)
if err != nil {
respondError(w, "resource not found", http.StatusNotFound)
respondAlert(w, "resource not found", "", "", http.StatusNotFound)
return
}
if !utils.CheckPassword(user, login.Password) {
respondError(w, "invalid credentials", http.StatusUnauthorized)
respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized)
return
}
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
respondError(w, "internal server error", http.StatusInternalServerError)
respondAlert(w, "internal server error", "", "", http.StatusInternalServerError)
return
}
@ -96,9 +107,108 @@ func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
func respondError(w http.ResponseWriter, error string, status int) {
func (h *IndexHandler) Signup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, "/summary", http.StatusFound)
return
}
switch r.Method {
case http.MethodPost:
h.handlePostSignup(w, r)
return
default:
h.handleGetSignup(w, r)
return
}
}
func (h *IndexHandler) handleGetSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, "/summary", http.StatusFound)
return
}
if handleAlerts(w, r, "signup.tpl.html") {
return
}
templates["signup.tpl.html"].Execute(w, nil)
}
func (h *IndexHandler) handlePostSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, "/summary", http.StatusFound)
return
}
var signup models.Signup
if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
return
}
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
return
}
if signup.Password != signup.PasswordRepeat {
respondAlert(w, "passwords do not match", "", "signup.tpl.html", http.StatusBadRequest)
return
}
_, created, err := h.userSrvc.CreateOrGet(&signup)
if err != nil {
respondAlert(w, "failed to create new user", "", "signup.tpl.html", http.StatusInternalServerError)
return
}
if !created {
respondAlert(w, "user already existing", "", "signup.tpl.html", http.StatusConflict)
return
}
msg := url.QueryEscape("account created successfully")
http.Redirect(w, r, fmt.Sprintf("/?success=%s", msg), http.StatusFound)
}
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
w.WriteHeader(status)
templates["index.tpl.html"].Execute(w, struct {
if tplName == "" {
tplName = "index.tpl.html"
}
templates[tplName].Execute(w, struct {
Error string
Success string
}{Error: error})
}
// TODO: do better
func handleAlerts(w http.ResponseWriter, r *http.Request, tplName string) bool {
if err := r.URL.Query().Get("error"); err != "" {
if err == "unauthorized" {
respondAlert(w, err, "", tplName, http.StatusUnauthorized)
} else {
respondAlert(w, err, "", tplName, http.StatusInternalServerError)
}
return true
}
if success := r.URL.Query().Get("success"); success != "" {
respondAlert(w, "", success, tplName, http.StatusOK)
return true
}
return false
}

View File

@ -6,6 +6,7 @@ import (
"html/template"
"io/ioutil"
"path"
"strings"
)
func init() {
@ -19,6 +20,8 @@ func loadTemplates() {
tpls := template.New("").Funcs(template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
"title": strings.Title,
"capitalize": utils.Capitalize,
})
templates = make(map[string]*template.Template)

View File

@ -1,8 +1,11 @@
package services
import (
"crypto/md5"
"encoding/hex"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/models"
uuid "github.com/satori/go.uuid"
)
type UserService struct {
@ -37,3 +40,26 @@ func (srv *UserService) GetAll() ([]*models.User, error) {
}
return users, nil
}
func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
pw := md5.Sum([]byte(signup.Password))
pwString := hex.EncodeToString(pw[:])
apiKey := uuid.NewV4().String()
u := &models.User{
ID: signup.Username,
ApiKey: apiKey,
Password: pwString,
}
result := srv.Db.FirstOrCreate(u, &models.User{ID: u.ID})
if err := result.Error; err != nil {
return nil, false, err
}
if result.RowsAffected == 1 {
return u, true, nil
}
return u, false, nil
}

10
utils/strings.go Normal file
View File

@ -0,0 +1,10 @@
package utils
import (
"fmt"
"strings"
)
func Capitalize(s string) string {
return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:])
}

View File

@ -1,7 +1,13 @@
{{ if .Error }}
<div class="flex justify-center w-full">
<div class="p-4 text-white text-sm bg-red-500 rounded mt-16 shadow flex-grow max-w-lg">
Error: {{ .Error }}
Error: {{ .Error | capitalize }}
</div>
</div>
{{ else if .Success }}
<div class="flex justify-center w-full">
<div class="p-4 text-white text-sm bg-green-500 rounded mt-16 shadow flex-grow max-w-lg">
{{ .Success | capitalize }}
</div>
</div>
{{ end }}

View File

@ -14,16 +14,20 @@
<form action="/login" method="post">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="text" id="username"
name="username" placeholder="Enter your username ..." required>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="text" id="username"
name="username" placeholder="Enter your username" required>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="password" id="password"
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password"
name="password" placeholder="******" required>
</div>
<div class="flex justify-between">
<a href="/signup">
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
</a>
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
</div>
</form>

42
views/signup.tpl.html Normal file
View File

@ -0,0 +1,42 @@
<html>
{{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="flex items-center justify-center">
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Sign Up</h1>
</div>
{{ template "alerts.tpl.html" . }}
<main class="mt-10 flex-grow flex justify-center w-full" id="grid-container">
<div class="flex-grow max-w-lg mt-12">
<form action="/signup" method="post">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="text" id="username"
name="username" placeholder="Choose a username" required>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="password" id="password"
name="password" placeholder="Choose a password" required>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">And again ...</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="password" id="password_repeat"
name="password_repeat" placeholder="Repeat your password" required>
</div>
<div class="flex justify-between float-right">
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Create Account</button>
</div>
</form>
</div>
</main>
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
</body>
</html>