working prototype

can push OTP request
can push routes
This commit is contained in:
Xavier Henner 2019-07-09 23:37:37 +02:00
parent f975a19f65
commit 274e824630
8 changed files with 205 additions and 96 deletions

10
ldap.go
View File

@ -21,11 +21,12 @@ type ldapConfig struct {
primaryAttribute string
secondaryAttribute string
validGroups []string
otpType string
mfaType string
certAuth string
ipMin net.IP
ipMax net.IP
upgradeFrom string
routes []string
}
func (l *ldapConfig) addIPRange(s string) error {
@ -69,11 +70,12 @@ func (conf *ldapConfig) Auth(logins []string, pass string) (e error, userOk, pas
}
}
if len(logins) != 1 {
return errors.New("invalid login"), false, false, nil
// no server ldap or multiple login should not happen here
if len(logins) != 1 || len(conf.servers) == 0 {
return nil, false, false, nil
}
attributes = logins
attributes = logins
for _, s := range conf.servers {
// we force ldaps because we can
l, err := myDialTLS("tcp", s+":636", &tls.Config{ServerName: s})

View File

@ -31,8 +31,6 @@ func main() {
server.CcPwnPassword = config.GetString("config.ccPwnPassword", "")
server.pwnTemplate = config.GetString("config.pwnTemplate", "")
server.newAsTemplate = config.GetString("config.newAsTemplate", "")
server.slackTemplate = config.GetString("config.slackTemplate", "")
server.slackTemplate2 = config.GetString("config.slackTemplate2", "")
server.cacheDir = config.GetString("config.cacheDir", "")
server.authCa = config.GetString("config.authCa", "")
server.otpMasterSecrets = parseConfigArray(config, "config.masterSecrets")
@ -62,7 +60,8 @@ func main() {
primaryAttribute: config.GetString(profile+".primaryAttribute", ""),
secondaryAttribute: config.GetString(profile+".secondaryAttribute", ""),
validGroups: parseConfigArray(config, profile+".validGroups"),
otpType: config.GetString(profile+".otp", ""),
routes: parseConfigArray(config, profile+".routes"),
mfaType: config.GetString(profile+".mfa", ""),
certAuth: config.GetString(profile+".cert", "optionnal"),
upgradeFrom: config.GetString(profile+".upgradeFrom", ""),
}

View File

@ -1,11 +0,0 @@
client-deny 0 0 "Need OTP" "CRV1:R:blabla:ZXVjbGlkZQ==:OTP Code "
client-auth 1 0
ifconfig-push 10.8.66.3 10.8.66.1
push "dhcp-option DNS 10.190.32.2"
push "dhcp-option DNS 10.190.32.20"
END

View File

@ -15,9 +15,9 @@ config
[
"CN=SEC_VPN_Users_External,OU=Security,OU=Groups,OU=Dailymotion,DC=office,DC=daily",
]
otp: "okta"
mfa: "okta"
cert: "ignore"
ip_range: "192.168.207.1 - 192.168.207.254",
IPRange: "192.168.207.1 - 192.168.207.254",
routes:
[
"10.189.10.9 255.255.255.255",
@ -45,10 +45,9 @@ config
[
"CN=SEC_VPN,OU=Security,OU=Groups,OU=Dailymotion,DC=office,DC=daily",
]
otp: "okta"
mfa: "okta"
cert: "optionnal"
upgrade-to: "DEV"
ip_range: "192.168.201.1 - 192.168.203.254"
IPRange: "192.168.201.1-192.168.203.254"
}
DEV:
{
@ -59,37 +58,41 @@ config
searchFilter: "(&(mail=%s))"
primaryAttribute: "description"
secondaryAttribute: "sshPublicKey"
upgrade-from: "CORP"
upgrade-to: "ADMINS"
otp: "okta"
upgradeFrom: "CORP"
mfa: ""
cert: "optionnal"
ip_range: "192.168.204.1 - 192.168.206.254"
IPRange: "192.168.204.1-192.168.206.254"
routes:
[
"10.190.32.51 255.255.255.255",
]
}
ADMINS:
{
validGroups:
[
"infra",
"infra2",
"net",
"datacenter",
]
upgrade-from: "DEV"
otp: [ "internal", "slack" ]
upgradeFrom: "DEV"
mfa: "internal"
cert: "mandatory"
ip_range: "192.168.200.2 - 192.168.200.254"
IPRange: "192.168.200.2-192.168.200.254"
}
}
openvpnPort: "127.0.0.1:4000"
httpPort: ":8443"
httpCa: "/usr/local/share/ca-certificates/Dailymotion.crt"
httpKey: "/etc/ssl/private/server-key.pem"
httpCert: "/etc/ssl/certs/server-bundle.pem"
cacheDir: "/var/run/openvpn/"
masterSecrets: [ "*******************************J" ]
authCa: "/usr/local/share/ca-certificates/Dailymotion.crt"
masterSecrets: [ "********************************"]
vpnLogUrl: "https://install.dm.gg/vpn-log.php"
slackToken: "*************************************************************************"
slackChannels: [ "#squad-it-office" ]
configParser: "/etc/openvpn/roadwarrior_([a-zA-Z0-9]*).conf"
mailRelay: "mailrelay.dailymotion.com:25"
mailFrom: "engineering-infra@dailymotion.com"
ccPwnPassword: "security-incident-report@dailymotion.com"
pwnTemplate: "Mime-Version: 1.0;\nContent-Type: text/html; charset=\"ISO-8859-1\";\nContent-Transfer-Encoding: 7bit;\nFrom: {{.MailFrom}}\nSubject: [Dailymotion] Your current okta password is compromised\nTo: {{.Mail}}\nCc: {{.CcPwnPassword}}\n\n<html><body>Hello<br>\n<br>\nWe have detected that you recently connected to the dailymotion's corporate VPN with login {{.Login}} and a password which was part a password-related breach - possibly related to your own account on a third party website - and which is now widely known to hackers.<br>\n<br>\nPlease contact the security team and go to the Okta homepage to change your password immediately : <a href=\"https://dailymotion.okta.com\">https://dailymotion.okta.com</a>/<br>\n<br>\nIf you were using the same unsafe password anywhere else, you should change it everywhere and make sure you use a unique password for every service (password managers make this feasible).<br>\n<br>\nWe remind you that you should always keep your passwords strong and strictly unique, especially when it comes to your dailymotion accounts. A robust password can, for example, be generated using an easily remembered phrase and retaining certain letters: for example, the phrase \"a bird in the hand is worth two in the bush\" would give the password \"1bitH=2itB\" (this example must not be used as a password).<br>\n<br>\nRegards,<br>\n<br>\n--<br>\nThe Dailymotion Security Team</body></html>"
newAsTemplate: "From: {{.MailFrom}}\nSubject: A new connection from you to the Dailymotion VPN\nTo: {{.Mail}}\n\nHello\n\nWe have detected a new connection to the vpn from {{.Login}}.\nIt was detected the {{.Time}} coming from the ip {{.IP}} ({{.AsName}}).\n\nIt's not the usual internet provider you connect to the Dailymotion VPN from.\nOr maybe it's the first time you use the VPN from this location.\n\nIf you think there is something suspicious, please contact {{.CcPwnPassword}}\nIf it's you who connected to the VPN, we are sorry for the spam. You won't receive another mail if you connect from this location.\n\nRegards,\n\n--\nThe Dailymotion Infra and Security Teams"
slackTemplate: "Hello.\nYou tried to connect to the VPN without an OTP code from your phone app.\nIf you have a Mac, use `{{.Login}}@{{.OTP}}` as your login.\nIf you have a PC with windows or linux, the VPN application should ask you for an OTP code after your login and password.\nThe OTP code is `{{.OTP}}`\n\nPS : you can check <https://wiki.dailymotion.com/display/officeit/OpenVPN#OpenVPN-OneTimePassword|the documentation> to learn how to use a proper OTP application and get rid of these messages"
slackTemplate2: "User *{{.Login}}* required the slack OTP token"
}

7
otp.go
View File

@ -1,7 +1,6 @@
package main
import (
"log"
"time"
)
@ -10,9 +9,9 @@ func (s *OpenVpnMgt) GenerateOTP(user string) ([]string, error) {
}
// alternative OTP generator, not used at the moment
func (s *OpenVpnMgt) GenerateSlackOTP(user string) ([]string, error) {
return s.GenerateOTPGeneric(user, 60, "sha256", 30, 8)
}
// func (s *OpenVpnMgt) GenerateSlackOTP(user string) ([]string, error) {
// return s.GenerateOTPGeneric(user, 60, "sha256", 30, 8)
// }
func (s *OpenVpnMgt) GenerateOTPGeneric(user string, period int, algo string, secretLen int, digits int) ([]string, error) {
codes := []string{}

33
roadwarrior.conf Normal file
View File

@ -0,0 +1,33 @@
#script-security 3
auth-user-pass-optional
ca /usr/local/share/ca-certificates/Dailymotion.crt
cert /etc/ssl/certs/vpn.dailymotion.com-cert.pem
user openvpn
cipher aes-128-cbc
dev vpnroadwarrior
dev-type tun
dh dh2048.pem
ifconfig 192.168.200.0 192.168.207.255
ifconfig-nowarn
keepalive 10 120
key /etc/ssl/private/vpn.dailymotion.com-key.pem
management 127.0.0.1 4000
management-client
management-client-auth
mode server
group openvpn
persist-key
persist-remote-ip
persist-tun
port 41690
proto tcp-server
push "dhcp-option DNS 10.190.32.2"
push "dhcp-option DNS 10.190.32.20"
push "topology p2p"
reneg-sec 43200
tls-auth tlsauth.key
tls-server
topology p2p
username-as-common-name
verb 4
client-cert-not-required

View File

@ -3,6 +3,7 @@ package main
import (
"bufio"
"errors"
"fmt"
"io"
"log"
"net"
@ -26,8 +27,6 @@ type OpenVpnMgt struct {
CcPwnPassword string
pwnTemplate string
newAsTemplate string
slackTemplate string
slackTemplate2 string
cacheDir string
syslog bool
otpMasterSecrets []string
@ -69,31 +68,40 @@ func (s *OpenVpnMgt) Run() {
}
}
func (s *OpenVpnMgt) TokenPassword(c *vpnSession) bool {
return false
func (s *OpenVpnMgt) TokenPassword(c *vpnSession) (bool, string) {
//TODO implement that correcly
if c.password == "maith1wiePuw3ieb4heiNie5y" {
return true, "maith1wiePuw3ieb4heiNie5y"
}
return false, "maith1wiePuw3ieb4heiNie5y"
}
func (s *OpenVpnMgt) Auth(c *vpnSession) (error, bool) {
// main authentication function.
// returns 0 if auth is valid
// returns 1 if an TOTP code is necessary
// returns a negative if auth is not valid
func (s *OpenVpnMgt) Auth(c *vpnSession) (error, int) {
// an empty password is not good
if c.password == "" {
return nil, false
return errors.New("Empty Password"), -1
}
// check if the password is a valid token validated for TOTP 2FA
tokenPassword := s.TokenPassword(c)
// If this is the case, empty the password to avoid checking it against the
// ldap server
if tokenPassword {
// check if the password is a valid token (see TOTP request)
tokenPasswordOk, tokenPassword := s.TokenPassword(c)
// password is a token. We remove it from the session object to
// avoid checking it against the ldap
if tokenPasswordOk {
c.password = ""
}
// if the otp is indicated, we check it against the valid codes as soon as
// if the otp is not empty, we check it against the valid codes as soon as
// possible
otpvalidated := false
if c.otpCode != "" {
codes, err := s.GenerateOTP(c.Login)
if err != nil {
return err, false
return err, -2
}
for _, possible := range codes {
if possible == c.otpCode {
@ -102,8 +110,6 @@ func (s *OpenVpnMgt) Auth(c *vpnSession) (error, bool) {
}
}
log.Println(otpvalidated)
c.Profile = ""
login := []string{c.Login}
pass := c.password
@ -114,8 +120,6 @@ func (s *OpenVpnMgt) Auth(c *vpnSession) (error, bool) {
if ldap.upgradeFrom != c.Profile {
continue
}
log.Printf("try %s with login %s\n", k, login)
err, userOk, passOk, secondary := ldap.Auth(login, pass)
// if there is an error, try the other configurations
@ -141,20 +145,38 @@ func (s *OpenVpnMgt) Auth(c *vpnSession) (error, bool) {
}
// we have either a positive auth ok a previous valid one
if passOk || c.Profile != "" || tokenPassword {
if passOk || c.Profile != "" || tokenPasswordOk {
c.Profile = k
}
}
}
// no profile update this turn, no need to continue
if n == c.Profile {
break
}
}
log.Println(c)
log.Println(s.ldap[c.Profile])
return nil, false
// no profile validated, we stop here
if c.Profile == "" {
return errors.New("Authentication Failed"), -3
}
// check the MFA requested by the secured profile
switch s.ldap[c.Profile].mfaType {
case "internal":
if otpvalidated {
return nil, 0
}
c.password = tokenPassword
return errors.New("Need OTP Code"), 1
case "okta":
//TODO implement okta MFA
return nil, -4
}
// no MFA requested, the login is valid
return nil, 0
}
func (s *OpenVpnMgt) sendCommand(msg []string) (error, []string) {
@ -162,6 +184,7 @@ func (s *OpenVpnMgt) sendCommand(msg []string) (error, []string) {
return errors.New("No openvpn server present"), nil
}
for _, line := range msg {
log.Println(line)
if _, err := s.buf.WriteString(line + "\r\n"); err != nil {
return err, nil
}
@ -192,36 +215,73 @@ func (s *OpenVpnMgt) Version() (error, []string) {
return nil, msg
}
func (s *OpenVpnMgt) ClientValidated(line string) {
//TODO manage that : find the client, log stuff
<-s.ret
}
func (s *OpenVpnMgt) ClientDisconnect(line string) {
msg := <-s.ret
log.Println(msg)
//TODO manage that : find the client, log stuff
<-s.ret
}
func (s *OpenVpnMgt) getIP(c *vpnSession) (string, error) {
// TODO implement
ip := s.ldap[c.Profile].ipMin
return ip.String(), nil
}
func (s *OpenVpnMgt) ClientConnect(line string) {
var cmd []string
var ip string
var errIP error
client := NewVPNSession("log in")
client.ParseSessionId(line)
infos := <-s.ret
client.ParseEnv(&infos)
if err := client.ParseEnv(&infos); err != nil {
log.Println(err)
return
}
err, ok := s.Auth(client)
if err != nil {
// if auth is ok, time to get an IP address
if ok == 0 {
ip, errIP = s.getIP(client)
if errIP != nil {
ok = -10
err = errIP
}
}
switch {
case ok == 0:
cmd = []string{
fmt.Sprintf("client-auth %d %d", client.cID, client.kID),
fmt.Sprintf("ifconfig-push %s %s", ip, client.localIP),
}
for _, r := range s.ldap[client.Profile].routes {
cmd = append(cmd, fmt.Sprintf("push \"route %s vpn_gateway\"", r))
}
cmd = append(cmd, "END")
case ok < 0:
cmd = []string{fmt.Sprintf("client-deny %d %d \"%s\" \"%s\"",
client.cID, client.kID, err, err)}
case ok == 1:
cmd = []string{fmt.Sprintf(
"client-deny %d %d \"Need OTP\" \"CRV1:R,E:%s:%s:OTP Code \"",
client.cID, client.kID, client.password, client.b64Login())}
}
if err, _ := s.sendCommand(cmd); err != nil {
log.Println(err)
}
if ok {
log.Println("auth ok")
}
// err, msg := s.sendCommand([]string{fmt.Sprintf("client-deny %d %d \"Need OTP\" \"CRV1:R:blabla:eC5oZW5uZXI=:OTP Code \"", client.cID, client.kID)})
// if err != nil {
// return
// }
// log.Println(msg)
return
}
func (s *OpenVpnMgt) handleConn(conn net.Conn) {
@ -259,6 +319,15 @@ func (s *OpenVpnMgt) handleConn(conn net.Conn) {
}
line = strings.Trim(line, "\n\r ")
// manage all "terminator" lines
for _, terminator := range []string{"END", ">CLIENT:ENV,END", "SUCCESS"} {
if strings.HasPrefix(line, terminator) {
s.ret <- response
response = nil
break
}
}
switch {
// a new openvpn server is connected
case strings.HasPrefix(line, ">INFO"):
@ -272,16 +341,15 @@ func (s *OpenVpnMgt) handleConn(conn net.Conn) {
// new bloc for a connect event.
// We start the receiving handler, which will wait for the Channel message
case strings.HasPrefix(line, ">CLIENT:ADDRESS"):
go s.ClientValidated(line)
case strings.HasPrefix(line, ">CLIENT:CONNECT"):
go s.ClientConnect(line)
// write the cumulated lines into the channel to the current handler
case strings.HasPrefix(line, "END") || strings.HasPrefix(line, ">CLIENT:ENV,END"):
s.ret <- response
response = nil
default:
response = append(response, line)
}
//log.Print(line)
log.Print(line)
}
}

View File

@ -4,6 +4,7 @@ import (
"encoding/base64"
"encoding/json"
"os"
"regexp"
"strconv"
"strings"
"time"
@ -31,6 +32,7 @@ type vpnSession struct {
dev string `json:"-"`
password string `json:"-"`
otpCode string `json:"-"`
localIP string `json:"-"`
}
func NewVPNSession(operation string) *vpnSession {
@ -44,6 +46,10 @@ func NewVPNSession(operation string) *vpnSession {
return &v
}
func (c *vpnSession) b64Login() string {
return base64.StdEncoding.EncodeToString([]byte(c.Login))
}
func (c *vpnSession) ParseSessionId(line string) error {
var err error
client_id := strings.Split(strings.Replace(line, ">CLIENT:CONNECT,", "", 1), ",")
@ -56,30 +62,39 @@ func (c *vpnSession) ParseSessionId(line string) error {
return nil
}
func (c *vpnSession) ParseEnv(infos *[]string) {
func (c *vpnSession) ParseEnv(infos *[]string) error {
var err error
r := regexp.MustCompile("[^a-zA-Z0-9./_@-]")
for _, line := range *infos {
p := strings.Split(strings.Replace(line, ">CLIENT:ENV,", "", 1), "=")
switch p[0] {
case "trusted_port":
c.port, _ = strconv.Atoi(p[1])
case "trusted_ip":
c.IP = p[1]
if c.port, err = strconv.Atoi(r.ReplaceAllString(p[1], "")); err != nil {
return err
}
case "untrusted_port":
c.port, _ = strconv.Atoi(p[1])
if c.port, err = strconv.Atoi(r.ReplaceAllString(p[1], "")); err != nil {
return err
}
case "trusted_ip":
c.IP = r.ReplaceAllString(p[1], "")
case "untrusted_ip":
c.IP = p[1]
c.IP = r.ReplaceAllString(p[1], "")
case "ifconfig_local":
c.localIP = r.ReplaceAllString(p[1], "")
case "password":
switch {
case strings.HasPrefix(c.password, "CRV1"):
split := strings.Split(c.password, ":")
case strings.HasPrefix(p[1], "CRV1"):
split := strings.Split(p[1], ":")
if len(split) != 5 {
break
}
c.password = split[2]
c.otpCode = split[4]
case strings.HasPrefix(c.password, "SCRV1"):
split := strings.Split(c.password, ":")
case strings.HasPrefix(p[1], "SCRV1"):
split := strings.Split(p[1], ":")
if len(split) != 3 {
break
}
@ -101,11 +116,12 @@ func (c *vpnSession) ParseEnv(infos *[]string) {
}
case "username":
c.Login = p[1]
c.Login = r.ReplaceAllString(p[1], "")
case "dev":
c.dev = p[1]
c.dev = r.ReplaceAllString(p[1], "")
}
}
return nil
}
func (c *vpnSession) String() string {