htpasswd: implement routines for working with .htpasswd #1

Merged
amery merged 1 commits from dev-karasz-httpasswd into master 1 year ago
  1. 4
      go.mod
  2. 8
      go.sum
  3. 324
      htpasswd/htpasswd.go
  4. 90
      htpasswd/htpasswd_test.go
  5. 4
      htpasswd/htpasswd_testdata

4
go.mod

@ -6,6 +6,7 @@ require github.com/mgechev/revive v1.3.3
require ( require (
github.com/BurntSushi/toml v1.3.2 // indirect github.com/BurntSushi/toml v1.3.2 // indirect
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5
github.com/chavacava/garif v0.0.0-20230608123814-4bd63c2919ab // indirect github.com/chavacava/garif v0.0.0-20230608123814-4bd63c2919ab // indirect
github.com/fatih/color v1.15.0 // indirect github.com/fatih/color v1.15.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect github.com/fatih/structtag v1.2.0 // indirect
@ -16,6 +17,7 @@ require (
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
golang.org/x/sys v0.11.0 // indirect golang.org/x/crypto v0.13.0
golang.org/x/sys v0.12.0 // indirect
golang.org/x/tools v0.12.0 // indirect golang.org/x/tools v0.12.0 // indirect
) )

8
go.sum

@ -1,5 +1,7 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
github.com/chavacava/garif v0.0.0-20230608123814-4bd63c2919ab h1:5JxePczlyGAtj6R1MUEFZ/UFud6FfsOejq7xLC2ZIb0= github.com/chavacava/garif v0.0.0-20230608123814-4bd63c2919ab h1:5JxePczlyGAtj6R1MUEFZ/UFud6FfsOejq7xLC2ZIb0=
github.com/chavacava/garif v0.0.0-20230608123814-4bd63c2919ab/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= github.com/chavacava/garif v0.0.0-20230608123814-4bd63c2919ab/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -35,11 +37,13 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss= golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss=
golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

324
htpasswd/htpasswd.go

@ -0,0 +1,324 @@
// Package htpasswd contains utilities for manipulating .htpasswd files
package htpasswd
import (
"bytes"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
"github.com/GehirnInc/crypt/apr1_crypt"
"golang.org/x/crypto/bcrypt"
)
// Passwds name => hash
type Passwds map[string]string
// HashAlgorithm enum for hashing algorithms
type HashAlgorithm string
const (
// HashAPR1 Apache MD5 crypt
HashAPR1 HashAlgorithm = "apr1"
// HashBCrypt bcrypt
HashBCrypt HashAlgorithm = "bcrypt"
// HashSHA SHA
HashSHA HashAlgorithm = "sha"
// HashSSHA Salted SHA
HashSSHA HashAlgorithm = "ssha"
)
// ParseHtpasswdFile parses a .htpasswd file
// and returns a Passwd type
func ParseHtpasswdFile(file string) (Passwds, error) {
htpasswdBytes, err := os.ReadFile(file)
if err != nil {
return nil, err
}
return ParseHtpasswd(htpasswdBytes)
}
// ParseHtpasswd parses a slice of bytes in htpasswd style
func ParseHtpasswd(htpasswdBytes []byte) (Passwds, error) {
lines := strings.Split(string(htpasswdBytes), "\n")
passwords := make(map[string]string)
var err error
for lineNumber, line := range lines {
line = strings.Trim(line, " ")
if len(line) == 0 {
// skipping empty lines
continue
}
parts := strings.Split(line, ":")
if ok, err := validLine(parts, lineNumber, line); !ok {
return passwords, err
}
parts = trimParts(parts)
_, exists := passwords[parts[0]]
if exists {
err = errors.New("invalid htpasswords file - user " +
parts[0] + " defined more than once")
return passwords, err
}
passwords[parts[0]] = parts[1]
}
return passwords, err
}
func validLine(parts []string, lineNumber int, line string) (bool, error) {
var err error
if len(parts) != 2 {
err = errors.New(fmt.Sprintln("invalid line", lineNumber+1,
"unexpected number of parts split by", ":", len(parts),
"instead of 2 in\"", line, "\""))
return false, err
}
return true, nil
}
func trimParts(parts []string) []string {
for i, part := range parts {
parts[i] = strings.Trim(part, " ")
}
return parts
}
// CreateUser creates a record in the named file with
// the named password and hash algorithm
func CreateUser(file, user, passwd string, algo HashAlgorithm) error {
pp, err := ParseHtpasswdFile(file)
if err != nil {
return err
}
if _, exists := pp[user]; exists {
return fmt.Errorf("user %s already exists", user)
}
h, err := createHash(passwd, algo)
if err != nil {
return err
}
pp[user] = h
return os.WriteFile(file, pp.toByte(), os.ModePerm)
}
// UpdateUser will update the password for the named user
// in the named file
func UpdateUser(file, user, passwd string, algo HashAlgorithm) error {
pp, err := ParseHtpasswdFile(file)
if err != nil {
return err
}
if _, exists := pp[user]; !exists {
return fmt.Errorf("user %s does not exist", user)
}
h, err := createHash(passwd, algo)
if err != nil {
return err
}
pp[user] = h
return os.WriteFile(file, pp.toByte(), os.ModePerm)
}
// DeleteUser deletes the named user from the named file
func DeleteUser(file, user string) error {
pp, err := ParseHtpasswdFile(file)
if err != nil {
return err
}
if _, exists := pp[user]; !exists {
return fmt.Errorf("user %s does not exist", user)
}
delete(pp, user)
return os.WriteFile(file, pp.toByte(), os.ModePerm)
}
// VerifyUser will check if the given user and password are matching
// with the content of the given file
func VerifyUser(file, user, passwd string) error {
pp, err := ParseHtpasswdFile(file)
if err != nil {
return err
}
return pp.Verify(user, passwd)
}
// Verify will check if the given user and password are matching
// with the given Passwd object content
func (pp Passwds) Verify(user, passwd string) error {
if _, ok := pp[user]; !ok {
return fmt.Errorf("user %s does not exist", user)
}
alg, err := identifyHash(pp[user])
if err != nil {
return fmt.Errorf("cannot identify algo %v", alg)
}
return verifyPass(passwd, pp[user], alg)
}
func verifyPass(pass, hash string, alg HashAlgorithm) error {
switch alg {
case HashAPR1:
return verifyAPR1(pass, hash)
case HashBCrypt:
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass))
case HashSHA:
return verifySHA(pass, hash)
case HashSSHA:
return verifySSHA(pass, hash)
}
return fmt.Errorf("unsupported hash algorithm %v", alg)
}
func verifyAPR1(password, hashedPassword string) error {
return apr1_crypt.New().Verify(hashedPassword, []byte(password))
}
func verifySHA(password, hashedPassword string) error {
eppS := hashedPassword[5:]
hash, err := base64.StdEncoding.DecodeString(eppS)
if err != nil {
return fmt.Errorf("cannot base64 decode")
}
sha := sha1.New()
_, err = sha.Write([]byte(password))
if err != nil {
return err
}
sum := sha.Sum(nil)
if !bytes.Equal(sum, hash) {
return fmt.Errorf("wrong password")
}
return nil
}
func verifySSHA(password, hashedPassword string) error {
eppS := hashedPassword[6:]
hash, err := base64.StdEncoding.DecodeString(eppS)
if err != nil {
return fmt.Errorf("cannot base64 decode")
}
salt := hash[len(hash)-4:]
sha := sha1.New()
_, err = sha.Write([]byte(password))
if err != nil {
return err
}
_, err = sha.Write(salt)
if err != nil {
return err
}
sum := sha.Sum(nil)
if !bytes.Equal(sum, hash[:len(hash)-4]) {
return fmt.Errorf("wrong password")
}
return nil
}
func (pp Passwds) toByte() []byte {
pass := []byte{}
for name, hash := range pp {
pass = append(pass, []byte(name+":"+hash+"\n")...)
}
return pass
}
func identifyHash(h string) (HashAlgorithm, error) {
switch {
case strings.HasPrefix(h, "$2a$"), strings.HasPrefix(h, "$2y$"),
strings.HasPrefix(h, "$2x$"), strings.HasPrefix(h, "$2b$"):
return HashBCrypt, nil
case strings.HasPrefix(h, "$apr1$"):
return HashAPR1, nil
case strings.HasPrefix(h, "{SHA}"):
return HashSHA, nil
case strings.HasPrefix(h, "{SSHA}"):
return HashSSHA, nil
}
return "", fmt.Errorf("unsupported hash algorithm")
}
func createHash(passwd string, algo HashAlgorithm) (string, error) {
hash := ""
prefix := ""
var err error
switch algo {
case HashAPR1:
hash, err = hashApr1(passwd)
case HashBCrypt:
hash, err = hashBcrypt(passwd)
case HashSHA:
prefix = "{SHA}"
hash, err = hashSha(passwd)
case HashSSHA:
prefix = "{SSHA}"
hash, err = hashSSha(passwd)
}
if err != nil {
return "", err
}
return prefix + hash, nil
}
func hashApr1(passwd string) (string, error) {
return apr1_crypt.New().Generate([]byte(passwd), nil)
}
func hashBcrypt(passwd string) (string, error) {
passwordBytes, err := bcrypt.GenerateFromPassword([]byte(passwd), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(passwordBytes), nil
}
func hashSha(passwd string) (string, error) {
s := sha1.New()
_, err := s.Write([]byte(passwd))
if err != nil {
return "", err
}
passwordSum := []byte(s.Sum(nil))
return base64.StdEncoding.EncodeToString(passwordSum), nil
}
func hashSSha(passwd string) (string, error) {
s := sha1.New()
_, err := s.Write([]byte(passwd))
if err != nil {
return "", err
}
salt := make([]byte, 4)
_, err = rand.Read(salt)
if err != nil {
return "", err
}
_, err = s.Write([]byte(salt))
if err != nil {
return "", err
}
passwordSum := []byte(s.Sum(nil))
ret := append(passwordSum, salt...)
return base64.StdEncoding.EncodeToString(ret), nil
}

90
htpasswd/htpasswd_test.go

@ -0,0 +1,90 @@
package htpasswd
import (
"os"
"testing"
)
func TestParseHtpassd(t *testing.T) {
passwords, err := ParseHtpasswd([]byte("sha:{SHA}IRRjboXT92QSYXm8lpGPCZUvU1E=\n"))
if err != nil {
t.Fatal(err)
}
if len(passwords) != 1 {
t.Fatalf("unexpected length in passwords, expected 1 got %v", len(passwords))
}
const expected = "{SHA}IRRjboXT92QSYXm8lpGPCZUvU1E="
if passwords["sha"] != expected {
t.Fatalf("sha password was wrong, got %s and expected %s", passwords["sha"], expected)
}
}
func TestCreateUser(t *testing.T) {
f, err := os.Create("htpasswd_testdata")
if err != nil {
t.Fatal(err)
}
testCases := []struct {
user, password string
hash HashAlgorithm
expected error
}{
{"apr1", "123456@", HashAPR1, nil},
{"bcrypt", "123456@", HashBCrypt, nil},
{"ssha", "123456@", HashSSHA, nil},
{"sha", "123456@", HashSHA, nil},
}
for _, tc := range testCases {
err := CreateUser(f.Name(), tc.user, tc.password, tc.hash)
if err != tc.expected {
t.Errorf("CreateUser(%s %s, %s, %s) = %d; want %d", f.Name(),
tc.user, tc.password, tc.hash, err, tc.expected)
}
}
}
func TestVerifyPassword(t *testing.T) {
f, err := os.Stat("htpasswd_testdata")
if err != nil {
TestCreateUser(t)
}
_, err = ParseHtpasswdFile(f.Name())
if err != nil {
t.Fatal(err)
}
testCases := []struct {
user, password string
expected error
}{
{"apr1", "123456@", nil},
{"bcrypt", "123456@", nil},
{"ssha", "123456@", nil},
{"sha", "123456@", nil},
}
for _, tc := range testCases {
err := VerifyUser(f.Name(), tc.user, tc.password)
if err != tc.expected {
t.Errorf("VerifyUser(%s %s, %s) = %v; want %v",
f, tc.user, tc.password, err, tc.expected)
}
}
}
func TestVerify(t *testing.T) {
f, err := os.Stat("htpasswd_testdata")
if err != nil {
TestCreateUser(t)
}
pp, err := ParseHtpasswdFile(f.Name())
if err != nil {
t.Fatal(err)
}
err = pp.Verify("bcrypt", "123456@")
if err != nil {
t.Fatalf(err.Error())
}
}

4
htpasswd/htpasswd_testdata

@ -0,0 +1,4 @@
bcrypt:$2a$10$9NdvqvFl9Yz9FM2D.i9Na.K1CiNF1ldk9hgRR57lYJiRBUnGt2THq
ssha:{SSHA}7fPHbfKv92vC/IFhKdnEKSTBKubvta9a
sha:{SHA}YEn9/RmoXLdbyB9TEDJ0OqWoPy8=
apr1:$apr1$U9.3kynN$MCKs53Oz35J0OYrSxfheW.
Loading…
Cancel
Save