validator is now login-validator and is part of the server image
This commit is contained in:
parent
fd58db5eb1
commit
ee6fbe3e74
11 changed files with 23 additions and 5 deletions
61
pkg/login-validator/cmd/arp.go
Normal file
61
pkg/login-validator/cmd/arp.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ARPTable string = "/proc/net/arp"
|
||||
|
||||
type ARPEntry struct {
|
||||
IP net.IP
|
||||
HWType int
|
||||
Flags int
|
||||
HWAddress net.HardwareAddr
|
||||
Mask string
|
||||
Device string
|
||||
}
|
||||
|
||||
func ARPAnalyze() (ents []ARPEntry, err error) {
|
||||
var content []byte
|
||||
if content, err = ioutil.ReadFile(ARPTable); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) > 5 {
|
||||
var e ARPEntry
|
||||
|
||||
if _, err := fmt.Sscanf(f[1], "0x%x", &e.HWType); err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := fmt.Sscanf(f[2], "0x%x", &e.Flags); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
e.IP = net.ParseIP(f[0])
|
||||
if e.HWAddress, err = net.ParseMAC(f[3]); err != nil {
|
||||
continue
|
||||
}
|
||||
e.Mask = f[4]
|
||||
e.Device = f[5]
|
||||
|
||||
ents = append(ents, e)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func ARPContainsIP(ents []ARPEntry, ip net.IP) *ARPEntry {
|
||||
for i, e := range ents {
|
||||
if e.IP.Equal(ip) && e.Flags == 2 {
|
||||
return &ents[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
78
pkg/login-validator/cmd/index.go
Normal file
78
pkg/login-validator/cmd/index.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
const indextpl = `<!DOCTYPE html>
|
||||
<html ng-app="FICApp">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Challenge Forensic - Administration</title>
|
||||
<link href="/css/bootstrap.min.css" type="text/css" rel="stylesheet">
|
||||
<link href="/css/glyphicon.css" type="text/css" rel="stylesheet" media="screen">
|
||||
<style>
|
||||
samp.cksum {
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 20vw;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
<script src="/js/d3.v3.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-light text-dark">
|
||||
<nav class="navbar sticky-top navbar-expand-lg navbar-dark bg-dark text-light" style="margin-bottom: 5px;">
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#adminMenu" aria-controls="adminMenu" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<span id="clock" class="navbar-text" ng-controller="CountdownController" ng-cloak>
|
||||
<span ng-show="startIn > 0">
|
||||
Démarrage dans :
|
||||
<span>{{"{{ startIn }}"}}</span>"
|
||||
<span class="point">|</span>
|
||||
</span>
|
||||
<span id="hours">{{"{{ time.hours | time }}"}}</span>
|
||||
<span class="point">:</span>
|
||||
<span id="min">{{"{{ time.minutes | time }}"}}</span>
|
||||
<span class="point">:</span>
|
||||
<span id="sec">{{"{{ time.seconds | time }}"}}</span>
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div class="container" ng-controller="DIWEBoxController">
|
||||
<div ng-repeat="box in boxes" class="alert alert-dismissible alert-{{"{{ box.kind }}"}}" ng-cloak>
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
<strong ng-if="box.title">{{"{{ box.title }}"}}</strong> {{"{{ box.msg }}"}}
|
||||
<ul ng-if="box.list">
|
||||
<li ng-repeat="i in box.list">{{"{{ i }}"}}</li>
|
||||
</ul>
|
||||
<button class="btn btn-sm btn-success" ng-if="box.yes || box.no" ng-click="box.yes()">Yes</button>
|
||||
<button class="btn btn-sm btn-danger" ng-if="box.yes || box.no" ng-click="box.no()">No</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container" ng-view></div>
|
||||
|
||||
<script src="/js/jquery.min.js"></script>
|
||||
<script src="/js/popper.min.js"></script>
|
||||
<script src="/js/bootstrap.min.js"></script>
|
||||
<script src="/js/angular.min.js"></script>
|
||||
<script src="/js/angular-route.min.js"></script>
|
||||
<script src="/js/angular-sanitize.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
if indexTmpl, err := template.New("index").Parse(indextpl); err != nil {
|
||||
http.Error(w, "Cannot create template: "+err.Error(), http.StatusInternalServerError)
|
||||
} else if err := indexTmpl.Execute(w, nil); err != nil {
|
||||
http.Error(w, "An error occurs during template execution: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
193
pkg/login-validator/cmd/login.go
Normal file
193
pkg/login-validator/cmd/login.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha512"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"gopkg.in/ldap.v2"
|
||||
)
|
||||
|
||||
var loginSalt string
|
||||
|
||||
type loginChecker struct {
|
||||
students []Student
|
||||
ldapAddr string
|
||||
ldapPort int
|
||||
ldapIsTLS bool
|
||||
ldapBase string
|
||||
ldapBindUsername string
|
||||
ldapBindPassword string
|
||||
}
|
||||
|
||||
type loginUpload struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func (l loginChecker) ldapAuth(username, password string) (res bool, err error) {
|
||||
tlsCnf := tls.Config{InsecureSkipVerify: true}
|
||||
|
||||
var c *ldap.Conn
|
||||
|
||||
if l.ldapIsTLS {
|
||||
c, err = ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", l.ldapAddr, l.ldapPort), &tlsCnf)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
} else {
|
||||
c, err = ldap.Dial("tcp", fmt.Sprintf("%s:%d", l.ldapAddr, l.ldapPort))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Reconnect with TLS
|
||||
err = c.StartTLS(&tlsCnf)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if l.ldapBindUsername != "" {
|
||||
err = c.Bind(l.ldapBindUsername, l.ldapBindPassword)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Search for the given username
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
l.ldapBase,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
fmt.Sprintf("(&(objectClass=person)(uid=%s))", username),
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
sr, err := c.Search(searchRequest)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(sr.Entries) != 1 {
|
||||
return false, errors.New("User does not exist or too many entries returned")
|
||||
}
|
||||
|
||||
userdn := sr.Entries[0].DN
|
||||
|
||||
err = c.Bind(userdn, password)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (l loginChecker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if addr := r.Header.Get("X-Forwarded-For"); addr != "" {
|
||||
r.RemoteAddr = addr
|
||||
}
|
||||
log.Printf("%s \"%s %s\" [%s]\n", r.RemoteAddr, r.Method, r.URL.Path, r.UserAgent())
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
|
||||
// Check request type and size
|
||||
if r.Method != "POST" {
|
||||
http.Error(w,
|
||||
"Invalid request",
|
||||
http.StatusBadRequest)
|
||||
return
|
||||
} else if r.ContentLength < 0 || r.ContentLength > 1023 {
|
||||
http.Error(w,
|
||||
"Request entity too large",
|
||||
http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
var lu loginUpload
|
||||
if err := dec.Decode(&lu); err != nil {
|
||||
http.Error(w,
|
||||
err.Error(),
|
||||
http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Perform login check
|
||||
canContinue := false
|
||||
for _, std := range l.students {
|
||||
if std.Login == lu.Username {
|
||||
canContinue = true
|
||||
}
|
||||
}
|
||||
|
||||
if !canContinue {
|
||||
log.Println("Login not found:", lu.Username, "at", r.RemoteAddr)
|
||||
http.Error(w, "Login not found in whitelist.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if ok, err := l.ldapAuth(lu.Username, lu.Password); err != nil {
|
||||
log.Println("Unable to perform authentication for", lu.Username, ":", err, "at", r.RemoteAddr)
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
} else if !ok {
|
||||
log.Println("Login failed:", lu.Username, "at", r.RemoteAddr)
|
||||
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := l.lateLoginAction(lu.Username, r.RemoteAddr); err != nil {
|
||||
log.Println("Error on late login action:", err)
|
||||
http.Error(w, "Internal server error. Please retry in a few minutes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Successful login of", lu.Username, "at", r.RemoteAddr)
|
||||
http.Error(w, "Success", http.StatusOK)
|
||||
}
|
||||
|
||||
func (l loginChecker) lateLoginAction(username, remoteAddr string) error {
|
||||
// Find corresponding MAC
|
||||
var fname string
|
||||
spl := strings.SplitN(remoteAddr, ":", 2)
|
||||
if ip := net.ParseIP(spl[0]); ip == nil {
|
||||
return errors.New("Unable to parse given IPv4: " + spl[0])
|
||||
} else if arptable, err := ARPAnalyze(); err != nil {
|
||||
return err
|
||||
} else if arpent := ARPContainsIP(arptable, ip); arpent == nil {
|
||||
return errors.New("Unable to find MAC in ARP table")
|
||||
} else {
|
||||
fname = fmt.Sprintf("%02x-%02x-%02x-%02x-%02x-%02x-%02x", arpent.HWType, arpent.HWAddress[0], arpent.HWAddress[1], arpent.HWAddress[2], arpent.HWAddress[3], arpent.HWAddress[4], arpent.HWAddress[5])
|
||||
}
|
||||
|
||||
if tpl, err := ioutil.ReadFile(path.Join(tftpDir, "pxelinux.cfg", "tpl")); err != nil {
|
||||
log.Println("Unable to open tpl: ", err)
|
||||
} else if file, err := os.OpenFile(path.Join(tftpDir, "pxelinux.cfg", fname), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(0644)); err != nil {
|
||||
log.Println("Unable to open destination file: ", err)
|
||||
} else {
|
||||
defer file.Close()
|
||||
|
||||
mac := hmac.New(sha512.New512_224, []byte(loginSalt))
|
||||
|
||||
if configTmpl, err := template.New("pxelinux.cfg").Parse(string(tpl)); err != nil {
|
||||
log.Println("Cannot create template: ", err)
|
||||
} else if err := configTmpl.Execute(file, map[string]string{"username": username, "remoteAddr": remoteAddr, "pkey": fmt.Sprintf("%x", mac.Sum([]byte(username))), "fname": fname}); err != nil {
|
||||
log.Println("An error occurs during template execution: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
45
pkg/login-validator/cmd/logout.go
Normal file
45
pkg/login-validator/cmd/logout.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func logout(w http.ResponseWriter, r *http.Request) {
|
||||
if addr := r.Header.Get("X-Forwarded-For"); addr != "" {
|
||||
r.RemoteAddr = addr
|
||||
}
|
||||
log.Printf("%s \"%s %s\" [%s]\n", r.RemoteAddr, r.Method, r.URL.Path, r.UserAgent())
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
|
||||
// Find corresponding MAC
|
||||
var fname string
|
||||
spl := strings.SplitN(r.RemoteAddr, ":", 2)
|
||||
if ip := net.ParseIP(spl[0]); ip == nil {
|
||||
http.Error(w, "Unable to parse given IPv4: "+spl[0], http.StatusInternalServerError)
|
||||
return
|
||||
} else if arptable, err := ARPAnalyze(); err != nil {
|
||||
http.Error(w, "Unable to logout: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
} else if arpent := ARPContainsIP(arptable, ip); arpent == nil {
|
||||
http.Error(w, "Unable to find MAC in ARP table to logout.", http.StatusInternalServerError)
|
||||
return
|
||||
} else {
|
||||
fname = fmt.Sprintf("%02x-%02x-%02x-%02x-%02x-%02x-%02x", arpent.HWType, arpent.HWAddress[0], arpent.HWAddress[1], arpent.HWAddress[2], arpent.HWAddress[3], arpent.HWAddress[4], arpent.HWAddress[5])
|
||||
}
|
||||
|
||||
if err := os.Remove(path.Join(tftpDir, "pxelinux.cfg", fname)); err != nil {
|
||||
log.Println("Error on logout action:", err)
|
||||
http.Error(w, "Unable to logout: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Successful logout from", r.RemoteAddr)
|
||||
http.Error(w, "Success", http.StatusOK)
|
||||
}
|
||||
53
pkg/login-validator/cmd/main.go
Normal file
53
pkg/login-validator/cmd/main.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var tftpDir string
|
||||
|
||||
func main() {
|
||||
var studentsFile string
|
||||
|
||||
var lc loginChecker
|
||||
|
||||
var bind = flag.String("bind", ":8081", "Bind port/socket")
|
||||
flag.StringVar(&studentsFile, "students", "./students.csv", "Path to a CSV file containing students list")
|
||||
flag.StringVar(&ARPTable, "arp", ARPTable, "Path to ARP table")
|
||||
flag.StringVar(&tftpDir, "tftpdir", "/var/tftp/", "Path to TFTPd directory")
|
||||
flag.StringVar(&loginSalt, "loginsalt", "adelina", "secret used in login HMAC")
|
||||
|
||||
flag.StringVar(&lc.ldapAddr, "ldaphost", "auth.cri.epita.fr", "LDAP host")
|
||||
flag.IntVar(&lc.ldapPort, "ldapport", 636, "LDAP port")
|
||||
flag.BoolVar(&lc.ldapIsTLS, "ldaptls", false, "Is LDAP connection LDAPS?")
|
||||
flag.StringVar(&lc.ldapBase, "ldapbase", "dc=epita,dc=net", "LDAP base")
|
||||
flag.StringVar(&lc.ldapBindUsername, "ldapbindusername", "", "LDAP user to use in order to perform bind (optional if search can be made anonymously)")
|
||||
flag.StringVar(&lc.ldapBindPassword, "ldapbindpassword", "", "Password for the bind user")
|
||||
flag.Parse()
|
||||
|
||||
var err error
|
||||
|
||||
// Sanitize options
|
||||
log.Println("Checking paths...")
|
||||
if tftpDir, err = filepath.Abs(tftpDir); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
lc.students, err = readStudentsList(studentsFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Println("Registering handlers...")
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", Index)
|
||||
mux.Handle("/login", lc)
|
||||
mux.HandleFunc("/logout", logout)
|
||||
http.HandleFunc("/", mux.ServeHTTP)
|
||||
|
||||
log.Println("Ready, listening on port", *bind)
|
||||
http.ListenAndServe(*bind, nil)
|
||||
}
|
||||
44
pkg/login-validator/cmd/pxetpl.go
Normal file
44
pkg/login-validator/cmd/pxetpl.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
const pxeUserTplPath = "pxelinux.cfg/tpl"
|
||||
const pxeUserPath = "pxelinux.cfg"
|
||||
|
||||
func RegisterUserMAC(ip net.IP, username string) error {
|
||||
if tab, err := ARPAnalyze(); err != nil {
|
||||
return err
|
||||
} else if ent := ARPContainsIP(tab, ip); ent == nil {
|
||||
return errors.New(fmt.Sprintf("Unable to find MAC address for given IP (%s)", ip))
|
||||
} else {
|
||||
return registerUser(fmt.Sprintf("%02X-%02X-%02X-%02X-%02X-%02X-%02X", ent.HWType, ent.HWAddress[0], ent.HWAddress[1], ent.HWAddress[2], ent.HWAddress[3], ent.HWAddress[4], ent.HWAddress[5]), username)
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterUserIP(ip net.IP, username string) error {
|
||||
return registerUser(fmt.Sprintf("%02X%02X%02X%02X", ip.To4()[0], ip.To4()[1], ip.To4()[2], ip.To4()[3]), username)
|
||||
}
|
||||
|
||||
func registerUser(filename string, username string) error {
|
||||
if pxeTplCnt, err := ioutil.ReadFile(path.Join(tftpDir, pxeUserTplPath)); err != nil {
|
||||
return err
|
||||
} else if userfd, err := os.OpenFile(path.Join(tftpDir, pxeUserPath, filename), os.O_RDWR|os.O_CREATE, 0644); err != nil {
|
||||
return err
|
||||
} else {
|
||||
defer userfd.Close()
|
||||
if pxeTmpl, err := template.New("pxeUser").Parse(string(pxeTplCnt)); err != nil {
|
||||
return err
|
||||
} else if err := pxeTmpl.Execute(userfd, map[string]string{"username": username}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
41
pkg/login-validator/cmd/ssh.go
Normal file
41
pkg/login-validator/cmd/ssh.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SSHkey struct {
|
||||
Size int
|
||||
Fingerprint string
|
||||
Comment string
|
||||
Algo string
|
||||
}
|
||||
|
||||
func SSHkeyAnalyse(key string) (s SSHkey, err error) {
|
||||
cmd := exec.Command("ssh-keygen", "-l", "-f", "-")
|
||||
cmd.Stdin = strings.NewReader(key)
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
if err = cmd.Run(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var validLine = regexp.MustCompile(`^([0-9]+)`)
|
||||
|
||||
for _, line := range strings.Split(out.String(), "\n") {
|
||||
if validLine.MatchString(line) {
|
||||
s.Size, err = fmt.Sscanf("%d", validLine.SubexpNames()[1])
|
||||
s.Fingerprint = validLine.SubexpNames()[2]
|
||||
s.Comment = validLine.SubexpNames()[3]
|
||||
s.Algo = validLine.SubexpNames()[4]
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
43
pkg/login-validator/cmd/students.go
Normal file
43
pkg/login-validator/cmd/students.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/csv"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Student struct {
|
||||
Lastname string
|
||||
Firstname string
|
||||
Login string
|
||||
EMail string
|
||||
Phone string
|
||||
}
|
||||
|
||||
func readStudentsList(studentsFile string) (stds []Student, err error) {
|
||||
if studentsFile, err = filepath.Abs(studentsFile); err != nil {
|
||||
return
|
||||
} else if fi, err := os.Open(studentsFile); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
r := csv.NewReader(bufio.NewReader(fi))
|
||||
if list, err := r.ReadAll(); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for _, i := range list {
|
||||
var s Student
|
||||
|
||||
s.Lastname = i[0]
|
||||
s.Firstname = i[1]
|
||||
s.Login = i[2]
|
||||
s.EMail = i[3]
|
||||
s.Phone = i[4]
|
||||
|
||||
stds = append(stds, s)
|
||||
}
|
||||
|
||||
return stds, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in a new issue