htpasswd: implement routines for working with .htpasswd #1
Merged
amery
merged 1 commits from dev-karasz-httpasswd
into master
1 year ago
5 changed files with 427 additions and 3 deletions
@ -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 |
||||
} |
@ -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()) |
||||
} |
||||
} |
Loading…
Reference in new issue