Browse Source

added TOTP auth support

master
Stefan Naumann 1 week ago
parent
commit
7b67dfa71a
  1. 33
      README.md
  2. 36
      auth/base.go
  3. 8
      auth/dummy.go
  4. 155
      auth/totp.go
  5. 1
      go.mod
  6. 4
      go.sum
  7. 13
      views/login_totp.html
  8. 2
      views/repo_edit.html
  9. 24
      views/setup_totp.html
  10. 3
      web/install.go
  11. 8
      web/user.go

33
README.md

@ -2,17 +2,17 @@
[![Build Status](https://mvo.stefannaumann.de/repo/state/2)](https://mvo.stefannaumann.de/public/2)
My Very Own CI-server (Continuous Integration). With it you can build Git-repositories with webhooks, i.e. whenever commits are pushed or on the press of a button.
My Very Own CI-server (Continuous Integration). With it you can build Git-repositories with webhooks, i.e. whenever commits are pushed, new versions released or on the press of a button.
mvoCI aims to be simple and do as much as is necessary and nothing more. No Plugins, no Modules, no 500 MB of RAM eaten (your mileage may vary).
mvoCI aims to be simple and do as much as is necessary and nothing more. It aims to have a small memory footprint, be easy to use and understand.
**Be advised, that mvoCI has shell access. It is your responsibility to secure your machine from damage by mvoCI.**
**Be advised, that mvoCI uses shell access for its building routines. It is your responsibility to secure your machine from damage or data leakage by mvoCI.**
## What it does
* organize Repositories and Builds of them
* build Repositories on webhook, on click "Build Now"
* have a rudimentary account management
* build Repositories on webhook or on click "Build Now"
* build and publish release-artifacts to Gitea automatically
* bindings to Gogs, Gitea, Gitbucket, Gitlab, Bitbucket and Github
## What it does not
@ -21,22 +21,16 @@ mvoCI aims to be simple and do as much as is necessary and nothing more. No Plug
* timed builds
* after build scripts
* SVN, Mercurial, VCS (although there should not be a real reason for this constraint)
* internationalization (currently only English)
* internationalization (only English)
## Requirements
### Build-time Dependencies
### Compile-Time Requirements
mvoCI uses the following golang libraries:
* github.com/jinzhu/gorm
* github.com/labstack/echo
* golang.org/x/crypto/bcrypt
* github.com/foolin/goview
* github.com/foolin/goview/supports/echoview-v4
Compile-Time Requirements are resolved by `go` automatically. Build with:
Install with:
```
go get github.com/jinzhu/gorm github.com/labstack/echo golang.org/x/crypto/bcrypt github.com/foolin/goview github.com/foolin/goview/supports/echoview-v4
make dist
```
### Database
@ -44,7 +38,7 @@ go get github.com/jinzhu/gorm github.com/labstack/echo golang.org/x/crypto/bcryp
You need a database server, or use SQLite as database backend. You may use one of the following:
* PostgreSQL
* MySQL / MariaDB
* MSSQL
* SQLServer
* SQLite3
## Set up
@ -52,7 +46,7 @@ You need a database server, or use SQLite as database backend. You may use one o
* ``./mvo --install`` for the installation dialogues
* ``./mvo`` for production mode
## Configuration
## Configuration and Usage
* Configuration options are in ``mvo.cfg``.
@ -79,7 +73,7 @@ parallel_builds -> (int) the number of build threads and therefore the numb
### Lock down
As stated earlier, it is recommended, that mvoCI is not executed as root. It should be used with as little rights are possible, but enough to be useful for your usecase.
Do not execute mvoCI as root. It should be used with as little permissions as possible, but enough to be useful for your usecase. Most builds execute untrusted code like automake scripts, Makefiles or the like - make sure, that this untrusted code cannot leak information from your system.
### systemd
@ -107,3 +101,6 @@ Environment=USER=mvo HOME=/home/mvo
WantedBy=multi-user.target
```
## Contribute

36
auth/base.go

@ -41,6 +41,7 @@ import (
type VerifyFunc func(*core.User, string, string, string, string) error
type SeedFunc func( string ) string
type EnableFunc func ( core.User, *core.AuthProvider ) ( string, error )
type SetupViewFunc func ( core.User, *core.AuthProvider ) ( string, error )
type EnableCommitFunc func ( core.User, *core.AuthProvider, string ) error
type SetSecretFunc func ( *core.User, string, string ) error
@ -64,6 +65,7 @@ type Provider struct {
Enable EnableFunc // enable authProvider, seed for authProvider.Extra, maybe initiate the second step of verification
EnableCommit EnableCommitFunc // second stage enabling - check the secret against user input, and return nil if successful
EnableView SetupViewFunc
SetSecret SetSecretFunc
Cap ProviderCap
@ -238,15 +240,29 @@ func LoginView ( name string ) string {
return ""
}
func SetupView ( name string ) string {
if name == "main" || name == "" {
return "setup_native"
}
func SetupView ( db *gorm.DB, user core.User, name string, challenge string ) ( string, string ) {
var view string
var c string = challenge
if p, ok := authProvider[ name ]; ok {
return "setup_" + p.Name
var prov core.AuthProvider
db.Model(core.AuthProvider{}).Where ( "user_id = ? AND type = ?", user.ID, name ).First ( &prov );
if prov.ID > 0 {
if p.EnableView != nil {
str, err := p.EnableView ( user, &prov )
db.Save ( &prov )
if err != nil {
return "", ""
}
c = str
}
view = "setup_" + p.Name
}
}
return ""
return view, c
}
func AuthSetMain ( db *gorm.DB, u *core.User, name string, module string, extra string ) error {
@ -261,7 +277,6 @@ func AuthSetMain ( db *gorm.DB, u *core.User, name string, module string, extra
// enable the given module if it is not enabled already
func EnsureEnabled ( db *gorm.DB, u core.User, name string, module string, extra string, prov *core.AuthProvider ) error {
core.Console.Fail ("Ensure Enabled", u.ID, name, module, extra )
var cnt int64
var p core.AuthProvider
db.Model(&core.AuthProvider{}).Where ( "user_id = ? AND type = ?", u.ID, module ).Count(&cnt).First( &p );
@ -272,7 +287,6 @@ func EnsureEnabled ( db *gorm.DB, u core.User, name string, module string, extra
return errors.New ("There are several instances of this auth module")
} else {
p = core.AuthProvider { Name: name, Type: module, Extra: extra, UserId: u.ID };
core.Console.Log ( p )
db.Create ( &p );
*prov = p
return nil
@ -284,15 +298,12 @@ func EnsureEnabled ( db *gorm.DB, u core.User, name string, module string, extra
// fin -> finished, login successful
// fail -> failed auth, probably wrong configuration
func FollowUp ( user core.User, step string ) string {
core.Console.Log ( "FollowUp", user.AuthExtra, step )
extra := authDecode ( user );
if extra.Enable == false {
return "fin";
}
core.Console.Log ("extra", extra)
order := strings.Split ( extra.Order, "," )
core.Console.Log ("order", order)
if len(order) >= 0 {
if step == "main" || step == "native" || step == "" {
return order[0]
@ -368,7 +379,6 @@ func SeedStep ( name string, stepExtra string ) string {
}
func appendAuth ( user core.User, prov core.AuthProvider ) ( string, error ) {
core.Console.Log ("appendAuth")
a := authDecode ( user )
if a.Enable == false {
a.Enable = true
@ -376,7 +386,6 @@ func appendAuth ( user core.User, prov core.AuthProvider ) ( string, error ) {
} else {
o := strings.Split ( a.Order, "," )
for _, v := range o {
core.Console.Log ("appendAuth", v)
if prov.Name == v {
return "", errors.New("Module is already enabled")
}
@ -435,7 +444,6 @@ func AuthEnable ( db *gorm.DB, user *core.User, name string ) ( string, error )
}
if err != nil {
core.Console.Log ("return errorcode")
return "", err
}

8
auth/dummy.go

@ -15,6 +15,7 @@ var dummyProvider = Provider {
Description : "Check that the user can read (do not use)",
Enable : dummyEnable,
EnableView : dummyEnableView,
EnableCommit : dummyEnableCommit,
Verify : dummyVerify,
Seed : dummySeed,
@ -44,8 +45,11 @@ func dummySeed ( stepExtra string ) string {
return strconv.FormatInt ( (rand.Int63()%10)+(int64(e)%10)*10, 10 )
}
func dummyEnable ( user core.User, prov *core.AuthProvider ) ( string, error ) {
core.Console.Log ("dummyEnable" )
func dummyEnable ( _ core.User, _ *core.AuthProvider ) ( string, error ) {
return "stage2", nil
}
func dummyEnableView ( user core.User, prov *core.AuthProvider ) ( string, error ) {
secret := strconv.FormatInt ( rand.Int63()%10, 10 )
challenge := strconv.FormatInt (rand.Int63()%10, 10)
prov.Extra = secret + "," + challenge

155
auth/totp.go

@ -3,10 +3,19 @@ package auth
import (
// "fmt"
"bytes"
"errors"
"strings"
"image/png"
"crypto/rand"
"encoding/hex"
"encoding/json"
"encoding/base64"
"git.snaums.de/snaums/mvoCI/core"
// "github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
)
var totpProvider = Provider {
@ -15,12 +24,13 @@ var totpProvider = Provider {
Description: "Timed one time passwords (TOTP) and back-up codes",
Verify : totpVerify,
SetSecret : totpSetSecret,
//SetSecret : totpSetSecret,
Enable: totpEnable,
EnableView: totpEnableView,
EnableCommit: totpEnableCommit,
Cap: ProviderCap{
Seed: false,
SetSecretCommit: false,
ValidateRegistration: true,
MainEnable: false,
Instantiable: false,
},
@ -30,28 +40,137 @@ func init () {
__init ( &totpProvider )
}
func totpVerify (user *core.User, stepExtra string, given string, extra string, _ string ) error {
if checkPassword ( user, given ) == true {
return nil;
} else {
return errors.New ("Incorrect login");
type totpExtra struct {
Issuer string
Email string
Secret string
Backup []string
}
func totpDecodeExtra ( extra string ) totpExtra {
var t totpExtra
rd := strings.NewReader ( extra )
if core.GenericJSONDecode ( rd, &t ) != nil {
return totpExtra{}
}
return t
}
func totpExtraMarshal ( t totpExtra ) string {
str, _ := json.Marshal ( t )
return string(str)
}
func totpVerify (user *core.User, stepExtra string, given string, e string, authProviderExtra string ) error {
extra := totpDecodeExtra ( authProviderExtra )
if extra.Secret == "" {
return errors.New("Invalid config")
}
core.Console.Fail ( extra )
if totp.Validate ( given, extra.Secret ) == true {
core.Console.Warn ("verify checked out ", given)
return nil
}
// backup codes
for i:=0; i<len(extra.Backup); i++ {
err := bcrypt.CompareHashAndPassword( []byte(extra.Backup[i]), []byte(given) )
if err == nil {
// TODO remove the used backup code and save the AuthProvider back to the database
core.Console.Warn ("Used back-code ", given)
return nil
}
}
return errors.New("Not correct")
}
func totpSetSecret ( user *core.User, pass1 string, pass2 string ) error {
if pass1 != pass2 {
return errors.New ("Passwords to not match");
func totpBackupCodes ( num int, length int ) ( []string, error ) {
var result []string
for i := 0; i<num; i++ {
var x []byte = make([]byte, length)
_, err := rand.Read ( x )
if err != nil {
return []string{}, err
}
var xstr string = hex.EncodeToString(x)
result = append ( result, xstr[0:15] )
}
user.Passhash = createPasshash ( pass1 );
return nil;
return result, nil
}
func totpBackupHash ( codes []string ) []string {
var result []string = make([]string, len(codes))
for i := 0; i<len(codes); i ++ {
h, _ := bcrypt.GenerateFromPassword ( []byte(codes[i]), 10 )
result[i] = string(h)
}
return result
}
func totpSetupView ( user *core.User ) error {
return nil;
func totpEnable ( user core.User, prov *core.AuthProvider ) ( string, error ) {
return "step2", nil
}
func totpCommitSecret ( user *core.User, given string, extra string ) error {
return nil;
func totpEnableView ( user core.User, prov *core.AuthProvider ) ( string, error ) {
backupCodes, err := totpBackupCodes ( 10, 20 );
if err != nil {
return "", err
}
backupCodeHashes := totpBackupHash ( backupCodes )
var extra totpExtra = totpExtra {
Issuer: "mvoCI",
Email: user.Email,
Backup: backupCodeHashes,
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: extra.Issuer,
AccountName: extra.Email,
})
if err != nil {
return "", err
}
extra.Secret = key.Secret()
var buf bytes.Buffer
var b64 string
img, err := key.Image (200, 200)
if err != nil {
return "", err
}
png.Encode ( &buf, img )
b64 = base64.StdEncoding.EncodeToString ( buf.Bytes() )
prov.Extra = totpExtraMarshal ( extra )
result := `Manual:
<dl>
<dt>Issuer</dt><dd>` + extra.Issuer + `</dd>
<dt>Email</dt><dd>` + extra.Email + `</dd>
<dt>Secret</dt><dd>` + extra.Secret + `</dd>
</dl>
<img src="data:image/png;base64,`+b64+`"><h3>Backup-Codes:</h3>
<pre>`
for i := 0; i<len(backupCodes); i++ {
result += backupCodes[i] + "\n"
}
result += "</pre>"
return result, nil
}
func totpEnableCommit ( user core.User, prov *core.AuthProvider, given string ) error {
extra := totpDecodeExtra ( prov.Extra )
if extra.Secret == "" {
return errors.New("Invalid config")
}
if totp.Validate ( given, extra.Secret ) {
return nil
}
return errors.New("Not correct")
}

1
go.mod

@ -7,6 +7,7 @@ require (
github.com/golang/protobuf v1.5.2 // indirect
github.com/labstack/echo/v4 v4.2.2 // indirect
github.com/mattn/go-colorable v0.1.8 // indirect
github.com/pquerna/otp v1.3.0 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b
golang.org/x/net v0.0.0-20210420210106-798c2154c571 // indirect
golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78

4
go.sum

@ -36,6 +36,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@ -231,6 +233,8 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs=
github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=

13
views/login_totp.html

@ -0,0 +1,13 @@
{{ define "content" }}
<div class="login-form">
<span class="login-header">Timed One Time Password (TOTP)</span>
<form method="post">
<div class="form-field">
<label for="auth_secret">TOTP-Code or Backup-Code</label>
<input type="text" name="auth_secret" placeholder="TOTP" autofocus />
</div>
<button class="btn-primary">Submit</button>
</form>
</div>
{{ end }}
{{ define "toolbar" }}{{ end }}

2
views/repo_edit.html

@ -19,7 +19,7 @@
<h2>{{ if eq .mode "add" }}Add Repository{{ else }}Edit Repository{{ end }}</h2>
{{ render_error_list .errors }}
<div class="infobox">
You must ensure that the Flux CI server has read permission for
You must ensure that the mvoCI server has read permission for
the clone URL that you specify below. The URL is stored unencrypted
in the database, thus you should <b>avoid</b> using the
<code class="repo_code">https://username:password@host/name</code> format.

24
views/setup_totp.html

@ -0,0 +1,24 @@
{{ define "toolbar" }}
<li>
<a href="/user/{{ .user.ID }}/auth/desetup/{{ .module }}">
<i class="fa fa-chevron-left"></i>Cancel
</a>
</li>
{{ end }}
{{ define "content" }}
<h2>TOTP Setup</h2>
<div class="infobox">
Timed One Time Passwords work by a shared secret between the server and the client app. Then the server and the client calculate codes based on the time (normally in 30 second brackets) independent from each other. Install an app like andOTP on Android, scan the QR code and enter one of the generated codes. Please also note the backup-codes, which can be used once if you loose your device storing the secret.
</div>
<form method="post">
{{ .challenge }}
<div class="form-field">
<label for="userinput">Your first TOTP code:</label>
<input type="text" name="userinput" />
</div>
<button class="btn-primary">Enable</button>
<button class="btn-cancel">Cancel</button>
</form>
{{ end }}

3
web/install.go

@ -85,8 +85,9 @@ func userConfigurationHandler ( ctx echo.Context ) ([]string, []string) {
return []string{"Error in the auth module", err.Error()},[]string{}
}
s.db.Create ( &usr );
if err := auth.AuthSetMain ( s.db, &usr, "native", "native", "" ); err == nil {
s.db.Create ( &usr );
s.db.Save ( &usr );
return []string{},[]string{"User creation successfully"}
} else {
return []string{"Error in the auth module", err.Error()},[]string{}

8
web/user.go

@ -179,7 +179,7 @@ func userAuthModuleEnable ( ctx echo.Context ) error {
if str == "" {
return ctx.Redirect ( http.StatusFound, "/user/me?success="+module+" enabled")
} else {
} else { // if str == stage2 it is transferred to the
// second step enabling is necessary
return ctx.Redirect ( http.StatusFound, "/user/me/auth/setup/"+module+"?challenge="+url.QueryEscape(str) )
}
@ -198,15 +198,15 @@ func userAuthModuleSetupView ( ctx echo.Context ) error {
}
challenge := ctx.QueryParam ( "challenge" )
view := auth.SetupView(module)
view, challenge := auth.SetupView(s.db, user, module, challenge)
if view == "" {
return ctx.Redirect ( http.StatusFound, "/user/me?error=Module not found")
}
return ctx.Render ( http.StatusOK, view, echo.Map{
"m": "edit",
"navPage": "profile",
"user": user,
"u": user,
"module": module,
"challenge": template.HTML ( challenge ),
"errors": []string{ctx.QueryParam("error")},
"success": []string{},

Loading…
Cancel
Save