Compare commits

..

3 Commits

Author SHA1 Message Date
Wancat
bc1095fc61 Migrate to cookie based session (secure cookie)
finish sign-in sign-up
2022-10-21 16:57:10 +08:00
Wancat
8661c0081d Add auth region, dealing with login form 2022-10-21 15:19:45 +08:00
Wancat
300d49874e Add signup page 2022-10-21 14:53:41 +08:00
11 changed files with 230 additions and 70 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
test.txt test.txt
ledger-quicknote ledger-quicknote
*.txt *.txt
.htpasswd

View File

@@ -6,31 +6,82 @@ import (
"fmt" "fmt"
"os" "os"
"strings" "strings"
"time"
"github.com/gorilla/securecookie"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
type AuthStore interface { type AuthStore interface {
Register(user, pass string) error Register(user, pass string) error
Authenticate(user, pass string) error Login(user, pass string) (token string, err error)
Verify(token string) (session Session, err error)
Remove(user string) error Remove(user string) error
} }
type Session struct {
User string
Expiry time.Time
}
type Htpasswd struct { type Htpasswd struct {
accounts map[string]string accounts map[string]string
filePath string filePath string
cookie *securecookie.SecureCookie
} }
func NewHtpasswd(path string) (AuthStore, error) { func New(path string, hashKey []byte) (AuthStore, error) {
s := Htpasswd{ s := Htpasswd{
filePath: path, filePath: path,
cookie: securecookie.New(hashKey, nil),
} }
err := s.read() err := s.read()
return s, err return s, err
} }
func (s Htpasswd) Register(user, pass string) (err error) {
if _, ok := s.accounts[user]; ok {
return errors.New("user already exists")
}
s.accounts[user], err = hash(pass)
if err != nil {
return
}
return s.write()
}
func (s Htpasswd) Login(user, pass string) (token string, err error) {
hashed, ok := s.accounts[user]
if !ok {
return "", errors.New("user not found")
}
err = bcrypt.CompareHashAndPassword([]byte(hashed), []byte(pass))
if err != nil {
return "", errors.New("wrong password")
}
session := Session{
User: user,
Expiry: time.Now().AddDate(0, 0, 7),
}
token, err = s.cookie.Encode("session", session)
if err != nil {
panic(err)
}
return
}
func (s Htpasswd) Verify(token string) (session Session, err error) {
err = s.cookie.Decode("session", token, &session)
return
}
func (s Htpasswd) Remove(user string) (err error) {
delete(s.accounts, user)
return s.write()
}
func (s *Htpasswd) read() (err error) { func (s *Htpasswd) read() (err error) {
file, err := os.Open(s.filePath) file, err := os.OpenFile(s.filePath, os.O_RDONLY|os.O_CREATE, 0600)
if err != nil { if err != nil {
return err return err
} }
@@ -66,27 +117,6 @@ func (s *Htpasswd) write() (err error) {
return nil return nil
} }
func (s Htpasswd) Register(user, pass string) (err error) {
s.accounts[user], err = hash(pass)
if err != nil {
return
}
return s.write()
}
func (s Htpasswd) Authenticate(user, pass string) (err error) {
hashed, ok := s.accounts[user]
if !ok {
return errors.New("user not found")
}
return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(pass))
}
func (s Htpasswd) Remove(user string) (err error) {
delete(s.accounts, user)
return s.write()
}
func hash(pass string) (string, error) { func hash(pass string) (string, error) {
output, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) output, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
return string(output), err return string(output), err

View File

@@ -5,6 +5,8 @@ import (
"io/ioutil" "io/ioutil"
"strings" "strings"
"testing" "testing"
"github.com/gorilla/securecookie"
) )
type User struct { type User struct {
@@ -14,6 +16,7 @@ type User struct {
} }
func TestHtpasswdSuccess(t *testing.T) { func TestHtpasswdSuccess(t *testing.T) {
hashKey := securecookie.GenerateRandomKey(32)
path := "/tmp/.htpasswd" path := "/tmp/.htpasswd"
user1 := User{ user1 := User{
user: "user", user: "user",
@@ -25,15 +28,23 @@ func TestHtpasswdSuccess(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
store, err := NewHtpasswd(path) store, err := New(path, hashKey)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = store.Authenticate(user1.user, user1.pass) token, err := store.Login(user1.user, user1.pass)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
session, err := store.Verify(token)
if err != nil {
t.Error(err)
}
if session.User != user1.user {
t.Fatalf("expected %s, got %s", user1.user, session.User)
}
user2 := User{ user2 := User{
user: "foo", user: "foo",
pass: "bar", pass: "bar",

1
go.mod
View File

@@ -10,6 +10,7 @@ require (
github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.10.0 // indirect github.com/go-playground/validator/v10 v10.10.0 // indirect
github.com/goccy/go-json v0.9.7 // indirect github.com/goccy/go-json v0.9.7 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/leodido/go-urn v1.2.1 // indirect github.com/leodido/go-urn v1.2.1 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-isatty v0.0.14 // indirect

2
go.sum
View File

@@ -19,6 +19,8 @@ github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGF
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=

98
main.go
View File

@@ -1,18 +1,14 @@
package main package main
import ( import (
"bytes"
"flag" "flag"
"fmt"
"io"
"log" "log"
"os" "net/http"
"os/exec"
"strings"
"text/template" "text/template"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/securecookie"
"github.com/lancatlin/ledger-quicknote/auth"
) )
var ledgerTpl *template.Template var ledgerTpl *template.Template
@@ -23,12 +19,13 @@ var LEDGER_INIT string
var WORKING_DIR string var WORKING_DIR string
var HOST string var HOST string
type TxData struct { var store auth.AuthStore
Action string `form:"action" binding:"required"`
Name string `form:"name"` const HTPASSWD_FILE = ".htpasswd"
Date string
Amount string `form:"amount" binding:"required"` type UserLogin struct {
Account string `form:"account"` Email string `form:"email" binding:"required"`
Password string `form:"password" binding:"required"`
} }
func init() { func init() {
@@ -37,13 +34,40 @@ func init() {
flag.StringVar(&LEDGER_INIT, "i", "", "ledger initiation file") flag.StringVar(&LEDGER_INIT, "i", "", "ledger initiation file")
flag.StringVar(&WORKING_DIR, "w", "", "ledger working directory") flag.StringVar(&WORKING_DIR, "w", "", "ledger working directory")
flag.StringVar(&HOST, "b", "127.0.0.1:8000", "binding address") flag.StringVar(&HOST, "b", "127.0.0.1:8000", "binding address")
var hashKey string
flag.StringVar(&hashKey, "s", "", "session secret")
flag.Parse() flag.Parse()
if hashKey == "" {
hashKey = string(securecookie.GenerateRandomKey(32))
log.Printf("Generate random session key: %s", hashKey)
}
var err error
store, err = auth.New(HTPASSWD_FILE, []byte(hashKey))
if err != nil {
panic(err)
}
} }
func main() { func main() {
r := gin.Default() r := gin.Default()
r.HTMLRender = loadTemplates("templates") r.HTMLRender = loadTemplates("templates")
r.GET("/", func(c *gin.Context) {
r.GET("/signup", func(c *gin.Context) {
c.HTML(200, "signup.html", nil)
})
r.GET("/signin", func(c *gin.Context) {
c.HTML(200, "signin.html", nil)
})
r.POST("/signup", signup)
r.POST("/signin", signin)
authZone := r.Group("", basicAuth)
authZone.GET("/dashboard", func(c *gin.Context) {
c.HTML(200, "index.html", struct { c.HTML(200, "index.html", struct {
Templates []*template.Template Templates []*template.Template
Scripts map[string][]string Scripts map[string][]string
@@ -53,7 +77,7 @@ func main() {
}) })
}) })
r.POST("/new", func(c *gin.Context) { authZone.POST("/new", func(c *gin.Context) {
var data TxData var data TxData
if err := c.ShouldBind(&data); err != nil { if err := c.ShouldBind(&data); err != nil {
c.AbortWithError(400, err) c.AbortWithError(400, err)
@@ -70,7 +94,7 @@ func main() {
}{tx}) }{tx})
}) })
r.POST("/submit", func(c *gin.Context) { authZone.POST("/submit", func(c *gin.Context) {
tx := c.PostForm("tx") tx := c.PostForm("tx")
if err := appendToFile(tx); err != nil { if err := appendToFile(tx); err != nil {
c.AbortWithError(500, err) c.AbortWithError(500, err)
@@ -82,7 +106,7 @@ func main() {
}{tx}) }{tx})
}) })
r.GET("/exec", func(c *gin.Context) { authZone.GET("/exec", func(c *gin.Context) {
name, _ := c.GetQuery("name") name, _ := c.GetQuery("name")
if err := executeScript(c.Writer, name); err != nil { if err := executeScript(c.Writer, name); err != nil {
c.AbortWithError(500, err) c.AbortWithError(500, err)
@@ -94,33 +118,19 @@ func main() {
log.Fatal(r.Run(HOST)) log.Fatal(r.Run(HOST))
} }
func newTx(data TxData) (result string, err error) { func basicAuth(c *gin.Context) {
data.Date = time.Now().Format("2006/01/02") cookie, err := c.Cookie("session")
var buf bytes.Buffer if err == http.ErrNoCookie {
err = ledgerTpl.ExecuteTemplate(&buf, data.Action, data) c.Redirect(303, "/signin")
return buf.String(), nil return
} }
session, err := store.Verify(cookie)
func appendToFile(tx string) (err error) {
f, err := os.OpenFile(LEDGER_FILE, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil { if err != nil {
return err c.Redirect(303, "/signin")
return
} }
defer f.Close() c.Set("user", UserLogin{
Email: session.User,
buf := strings.NewReader(strings.ReplaceAll(tx, "\r", "")) // Remove CR generated from browser })
_, err = io.Copy(f, buf) c.Next()
return err
}
func executeScript(w io.Writer, name string) (err error) {
script, ok := SCRIPTS[name]
if !ok {
return fmt.Errorf("%s script not found", name)
}
cmd := exec.Command("ledger", append([]string{"--init-file", LEDGER_INIT, "--file", LEDGER_FILE}, script...)...)
cmd.Dir = WORKING_DIR
cmd.Stdout = w
cmd.Stderr = w
return cmd.Run()
} }

31
session.go Normal file
View File

@@ -0,0 +1,31 @@
package main
import "github.com/gin-gonic/gin"
func signup(c *gin.Context) {
var user UserLogin
if err := c.ShouldBind(&user); err != nil {
c.HTML(400, "signup.html", err)
return
}
if err := store.Register(user.Email, user.Password); err != nil {
c.HTML(400, "signup.html", err)
return
}
signin(c)
}
func signin(c *gin.Context) {
var user UserLogin
if err := c.ShouldBind(&user); err != nil {
c.HTML(400, "signin.html", err)
return
}
token, err := store.Login(user.Email, user.Password)
if err != nil {
c.HTML(401, "signin.html", err)
return
}
c.SetCookie("session", token, 60*60*24*7, "", "", false, false)
c.Redirect(303, "/dashboard")
}

4
templates/error.html Normal file
View File

@@ -0,0 +1,4 @@
{{ define "title" }}Error{{ end }}
{{ define "main" }}
<p class="error">{{ .Error }}</p>
{{ end }}

10
templates/signin.html Normal file
View File

@@ -0,0 +1,10 @@
{{ define "title" }}Sign Up{{ end }}
{{ define "main" }}
<h1>Sign In</h1>
<form action="/signin" method="POST">
<label>Email: <input type="text" name="email"></label><br>
<label>Password: <input type="password" name="password"></label><br>
{{ with .Error }}<p class="error">{{ . }}</p>{{ end }}
<input type="submit" value="Sign Up">
</form>
{{ end }}

10
templates/signup.html Normal file
View File

@@ -0,0 +1,10 @@
{{ define "title" }}Sign Up{{ end }}
{{ define "main" }}
<h1>Sign Up</h1>
<form action="/signup" method="POST">
<label>Email: <input type="text" name="email"></label><br>
<label>Password: <input type="password" name="password"></label><br>
{{ with .Error }}<p class="error">{{ . }}</p>{{ end }}
<input type="submit" value="Sign Up">
</form>
{{ end }}

50
tx.go Normal file
View File

@@ -0,0 +1,50 @@
package main
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
)
type TxData struct {
Action string `form:"action" binding:"required"`
Name string `form:"name"`
Date string
Amount string `form:"amount" binding:"required"`
Account string `form:"account"`
}
func newTx(data TxData) (result string, err error) {
data.Date = time.Now().Format("2006/01/02")
var buf bytes.Buffer
err = ledgerTpl.ExecuteTemplate(&buf, data.Action, data)
return buf.String(), nil
}
func appendToFile(tx string) (err error) {
f, err := os.OpenFile(LEDGER_FILE, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
return err
}
defer f.Close()
buf := strings.NewReader(strings.ReplaceAll(tx, "\r", "")) // Remove CR generated from browser
_, err = io.Copy(f, buf)
return err
}
func executeScript(w io.Writer, name string) (err error) {
script, ok := SCRIPTS[name]
if !ok {
return fmt.Errorf("%s script not found", name)
}
cmd := exec.Command("ledger", append([]string{"--init-file", LEDGER_INIT, "--file", LEDGER_FILE}, script...)...)
cmd.Dir = WORKING_DIR
cmd.Stdout = w
cmd.Stderr = w
return cmd.Run()
}